From b568e23133967f013019c59f2c0f7cf38a375aaa Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 17 Jul 2025 08:49:15 -0700 Subject: [PATCH 01/23] WIP - pre-download audio files for queued tracks --- backend/app.go | 11 ++++++++- sharedutil/sharedutil.go | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/app.go b/backend/app.go index b63bf38..af3af30 100644 --- a/backend/app.go +++ b/backend/app.go @@ -44,6 +44,7 @@ type App struct { Config *Config ServerManager *ServerManager ImageManager *ImageManager + AudioCache *AudioCache PlaybackManager *PlaybackManager LocalPlayer *mpv.Player UpdateChecker UpdateChecker @@ -142,8 +143,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas } a.ServerManager = NewServerManager(appName, appVersion, a.Config, !portableMode && a.Config.Application.EnablePasswordStorage) - a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir) + ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, "audio")) + if err != nil { + log.Printf("failed to create audio cache: %s", err.Error()) + } + a.AudioCache = ac + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { @@ -475,6 +481,9 @@ func (a *App) Shutdown() { } a.PlaybackManager.DisableCallbacks() a.PlaybackManager.Shutdown() // will trigger scrobble check + if a.AudioCache != nil { + a.AudioCache.Shutdown() + } a.cancel() a.LocalPlayer.Destroy() } diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index cf5bccb..e094364 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -1,6 +1,12 @@ package sharedutil import ( + "context" + "fmt" + "io" + "net/http" + "os" + "github.com/dweymouth/supersonic/backend/mediaprovider" ) @@ -133,3 +139,50 @@ func ReorderItems[T any](items []T, idxToMove []int, insertIdx int) []T { return newItems } + +// DownloadFileWithContext downloads a file from the specified URL and saves it to destPath. +// It respects the provided context and will cancel the request and cleanup if context is done. +// Returns an error if an error other than cancellation occurs, and returns true IFF the file was completely downloaded. +func DownloadFileWithContext(ctx context.Context, url string, destPath string) (bool, error) { + // Create HTTP request with context + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, fmt.Errorf("creating request: %w", err) + } + + // Perform the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false, fmt.Errorf("performing request: %w", err) + } + defer resp.Body.Close() + + // Check for non-200 status codes + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("bad status: %s", resp.Status) + } + + // Create the destination file + out, err := os.Create(destPath) + if err != nil { + return false, fmt.Errorf("creating file: %w", err) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + + select { + case <-ctx.Done(): + // Cancelled, delete partial file + out.Close() + os.Remove(destPath) + return false, nil + default: + if err != nil { + os.Remove(destPath) + return false, fmt.Errorf("error copying data: %w", err) + } + } + + return true, nil +} From bcc000704249a9695d76cbde08e02ba56779e620 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 18 Jul 2025 17:36:39 -0700 Subject: [PATCH 02/23] pre-cache next two files in queue; TODO - use them --- backend/app.go | 2 +- backend/audiocache.go | 135 +++++++++++++++++++++++++++++++++++++ backend/playbackengine.go | 61 ++++++++++++----- backend/playbackmanager.go | 3 +- 4 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 backend/audiocache.go diff --git a/backend/app.go b/backend/app.go index af3af30..5173a4f 100644 --- a/backend/app.go +++ b/backend/app.go @@ -149,7 +149,7 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas log.Printf("failed to create audio cache: %s", err.Error()) } a.AudioCache = ac - a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) + a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.AudioCache, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576) a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) { diff --git a/backend/audiocache.go b/backend/audiocache.go new file mode 100644 index 0000000..965d14d --- /dev/null +++ b/backend/audiocache.go @@ -0,0 +1,135 @@ +package backend + +import ( + "context" + "errors" + "log" + "os" + "path/filepath" + "slices" + "sync" + + "github.com/20after4/configdir" + "github.com/dweymouth/supersonic/sharedutil" +) + +// AudioCache manages temporary local storage of audio files fetched from the remote music server. +// It prefetches and stores tracks on disk based on an upcoming play queue. +type AudioCache struct { + mutex sync.Mutex + + s *ServerManager + rootCtx context.Context + baseCacheDir string + + entries map[string]*cacheEntry +} + +type cacheEntry struct { + done bool + cancel context.CancelFunc +} + +// AudioCacheRequest represents a request to prefetch and cache an audio file. +type AudioCacheRequest struct { + ID string + DownloadURL string +} + +// NewAudioCache initializes an AudioCache using the given context, server manager, +// and local filesystem directory for storing audio files. +func NewAudioCache(ctx context.Context, s *ServerManager, baseCacheDir string) (*AudioCache, error) { + if err := configdir.MakePath(baseCacheDir); err != nil { + return nil, errors.New("failed to create audio cache dir") + } + return &AudioCache{ + s: s, + rootCtx: ctx, + baseCacheDir: baseCacheDir, + entries: make(map[string]*cacheEntry), + }, nil +} + +// PathForCachedFile returns the local filesystem path for a cached track, +// if the file has finished downloading. If not cached, it returns an empty string. +func (a *AudioCache) PathForCachedFile(id string) string { + if item, ok := a.entries[id]; ok && item.done { + return a.pathForID(id) + } + return "" +} + +// cacheFile begins downloading a file (if not already downloading) and stores it +// to the cache directory under its ID as filename. The download is asynchronous. +func (a *AudioCache) cacheFile(id, dlURL string) { + s := a.s.Server + if s == nil { + return + } + + if _, ok := a.entries[id]; !ok { + ctx, cancel := context.WithCancel(a.rootCtx) + a.entries[id] = &cacheEntry{cancel: cancel} + go func() { + ok, err := sharedutil.DownloadFileWithContext(ctx, dlURL, a.pathForID(id)) + if ok { + a.mutex.Lock() + if e, ok := a.entries[id]; ok { + e.done = true + } + a.mutex.Unlock() + } else if err != context.DeadlineExceeded { + log.Printf("error downloading audio file: %v", err) + } + cancel() // release ctx resources when done + }() + } +} + +// CacheOnly ensures that only the given 'fetch' list of files (plus one extra 'keep' ID) remain cached. +// Any other cached files are cancelled and deleted from disk. +func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { + a.mutex.Lock() + defer a.mutex.Unlock() + + // delete files we're not keeping + for id, e := range a.entries { + if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { + return a.ID == id + }) { + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) + } + } + + // start caching the ones from fetch if not already present + for _, item := range fetch { + if _, ok := a.entries[item.ID]; !ok { + a.cacheFile(item.ID, item.DownloadURL) + } + } +} + +// Shutdown cancels all in-progress downloads and deletes all files in the audio cache directory. +// This should be called during application shutdown to clean up temporary audio data. +func (a *AudioCache) Shutdown() { + a.mutex.Lock() + // Cancel all active downloads + for _, entry := range a.entries { + if entry.cancel != nil { + entry.cancel() + } + } + a.entries = nil // clear cache state + a.mutex.Unlock() + + // Remove all files in the cache directory + if a.baseCacheDir != "" { + _ = os.RemoveAll(a.baseCacheDir) + } +} + +func (a *AudioCache) pathForID(id string) string { + return filepath.Join(a.baseCacheDir, id) +} diff --git a/backend/playbackengine.go b/backend/playbackengine.go index fa54680..cad0e6d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -52,6 +52,7 @@ type playbackEngine struct { ctx context.Context cancelPollPos context.CancelFunc sm *ServerManager + audiocache *AudioCache player player.BasePlayer playTimeStopwatch util.Stopwatch @@ -98,6 +99,7 @@ type playbackEngine struct { func NewPlaybackEngine( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, @@ -108,6 +110,7 @@ func NewPlaybackEngine( pm := &playbackEngine{ ctx: ctx, sm: s, + audiocache: c, player: p, scrobbleCfg: scrobbleCfg, transcodeCfg: transcodeCfg, @@ -555,6 +558,25 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { }) } +func (p *playbackEngine) cacheNextTracks() { + if p.audiocache != nil { + // fetch up to the 2 next tracks in the queue to the cache + fetch := make([]AudioCacheRequest, 0, 2) + for _, idx := range [2]int{p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} { + if idx < len(p.playQueue) { + item := p.playQueue[idx] + if item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + fetch = append(fetch, AudioCacheRequest{ + ID: p.playQueue[idx].Metadata().ID, + DownloadURL: p.getMediaURLForIdx(idx), + }) + } + } + } + p.audiocache.CacheOnly(p.NowPlaying().Metadata().ID, fetch) + } +} + func (p *playbackEngine) handleOnTrackChange() { // scrobble the previous song if needed if !p.alreadyScrobbled { @@ -585,6 +607,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.doUpdateTimePos(false) + p.cacheNextTracks() p.setNextTrackBasedOnLoopMode(false) } @@ -648,23 +671,9 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { url := "" var meta mediaprovider.MediaItemMetadata if idx >= 0 { - var err error - item := p.playQueue[idx] - meta = item.Metadata() - if tr, ok := item.(*mediaprovider.Track); ok { - var ts *mediaprovider.TranscodeSettings - if p.transcodeCfg.RequestTranscode { - ts = &mediaprovider.TranscodeSettings{ - Codec: p.transcodeCfg.Codec, - BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, - } - } - url, err = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) - } else { - url = item.(*mediaprovider.RadioStation).StreamURL - } - if err != nil { - return err + url = p.getMediaURLForIdx(idx) + if url == "" { + return errors.New("no stream URL") } } if next { @@ -687,6 +696,24 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { panic("Unsupported player type") } +func (p *playbackEngine) getMediaURLForIdx(idx int) string { + var url string + item := p.playQueue[idx] + if tr, ok := item.(*mediaprovider.Track); ok { + var ts *mediaprovider.TranscodeSettings + if p.transcodeCfg.RequestTranscode { + ts = &mediaprovider.TranscodeSettings{ + Codec: p.transcodeCfg.Codec, + BitRateKBPS: p.transcodeCfg.MaxBitRateKBPS, + } + } + url, _ = p.sm.Server.GetStreamURL(tr.ID, ts, p.transcodeCfg.ForceRawFile) + } else { + url = item.(*mediaprovider.RadioStation).StreamURL + } + return url +} + func (p *playbackEngine) setNextTrack(idx int) error { return p.setTrack(idx, true, 0) } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 153760f..c331747 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -46,13 +46,14 @@ type RemotePlaybackDevice struct { func NewPlaybackManager( ctx context.Context, s *ServerManager, + c *AudioCache, p player.BasePlayer, playbackCfg *PlaybackConfig, scrobbleCfg *ScrobbleConfig, transcodeCfg *TranscodingConfig, appCfg *AppConfig, ) *PlaybackManager { - e := NewPlaybackEngine(ctx, s, p, playbackCfg, scrobbleCfg, transcodeCfg) + e := NewPlaybackEngine(ctx, s, c, p, playbackCfg, scrobbleCfg, transcodeCfg) q := NewCommandQueue() pm := &PlaybackManager{ engine: e, From dfa493501d2994b6f58dc67a9ebb3f71194ee4c9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 19 Jul 2025 09:33:15 -0700 Subject: [PATCH 03/23] wait to set next track until near end of current. (still needs more testing/review) --- backend/playbackengine.go | 107 ++++++++++++++---------------- backend/player/dlna/dlnaplayer.go | 8 ++- 2 files changed, 58 insertions(+), 57 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index cad0e6d..c2cc0ea 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -77,6 +77,11 @@ type playbackEngine struct { pendingPlayerChange bool pendingPlayerChangeStatus player.Status + // Whether we need to set the next track on the Player + // before the current track completes (normally when 10 seconds remain + // in the time pos polling function) + needToSetNextTrack bool + // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track scrobbleCfg *ScrobbleConfig @@ -135,7 +140,7 @@ func NewPlaybackEngine( func (p *playbackEngine) registerPlayerCallbacks(pl player.BasePlayer) { pl.OnTrackChange(p.handleOnTrackChange) pl.OnSeek(func() { - p.doUpdateTimePos(true) + p.handleTimePosUpdate(true) p.invokeNoArgCallbacks(p.onSeek) }) pl.OnStopped(p.handleOnStopped) @@ -239,7 +244,10 @@ func (p *playbackEngine) NowPlayingIndex() int { func (p *playbackEngine) SetLoopMode(loopMode LoopMode) { p.loopMode = loopMode if p.nowPlayingIdx >= 0 { - p.setNextTrackBasedOnLoopMode(true) + // TODO - don't need when going from LoopNone to LoopAll + // if not on last track + p.needToSetNextTrack = true + //p.setNextTrackBasedOnLoopMode(true) } for _, cb := range p.onLoopModeChange { @@ -304,7 +312,7 @@ func (p *playbackEngine) SeekFwdBackN(n int) error { if idx == lastIdx && n > 0 { return nil // already on last track, nothing to seek next to } - newIdx := minInt(lastIdx, maxInt(0, idx+n)) + newIdx := min(lastIdx, max(0, idx+n)) return p.PlayTrackAt(newIdx) } @@ -360,7 +368,7 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu p.nowPlayingIdx = -1 p.playQueue = nil } - needToSetNext := len(items) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) + p.needToSetNextTrack = len(items) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) if shuffle { rand.Shuffle(len(items), func(i, j int) { items[i], items[j] = items[j], items[i] }) @@ -372,9 +380,9 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu } p.playQueue = append(p.playQueue[:insertIdx], append(items, p.playQueue[insertIdx:]...)...) - if needToSetNext { - p.setNextTrack(p.nowPlayingIdx + 1) - } + //if needToSetNext { + // p.setNextTrack(p.nowPlayingIdx + 1) + //} p.invokeNoArgCallbacks(p.onQueueChange) return nil @@ -386,7 +394,7 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins p.nowPlayingIdx = -1 p.playQueue = nil } - needToSetNext := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1) + p.needToSetNextTrack = insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1) insertIdx := len(p.playQueue) if insertMode == InsertNext { insertIdx = p.nowPlayingIdx + 1 @@ -398,9 +406,9 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:]) p.playQueue = new - if needToSetNext { - p.setNextTrack(p.nowPlayingIdx + 1) - } + //if needToSetNext { + // p.setNextTrack(p.nowPlayingIdx + 1) + //} p.invokeNoArgCallbacks(p.onQueueChange) } @@ -460,11 +468,11 @@ func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { return p.Stop() } - needToUpdateNext := p.nowPlayingIdx >= 0 + p.needToSetNextTrack = p.nowPlayingIdx >= 0 p.nowPlayingIdx = newNowPlayingIdx - if needToUpdateNext { - p.setNextTrackAfterQueueUpdate() - } + //if needToUpdateNext { + // p.setNextTrackBasedOnLoopMode(false) + //} p.invokeNoArgCallbacks(p.onQueueChange) return nil @@ -510,7 +518,8 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { // when we receive new track event from player } else if isNextPlayingTrackremoved { if newNowPlaying < len(newQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) + p.needToSetNextTrack = true + //p.setNextTrack(p.nowPlayingIdx + 1) } else { // no next track to play p.setNextTrack(-1) @@ -606,7 +615,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.curTrackDuration = float64(nowPlaying.Metadata().Duration) p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() - p.doUpdateTimePos(false) + p.handleTimePosUpdate(false) p.cacheNextTracks() p.setNextTrackBasedOnLoopMode(false) } @@ -617,7 +626,7 @@ func (p *playbackEngine) handleOnStopped() { p.checkScrobble() } p.stopPollTimePos() - p.doUpdateTimePos(false) + p.handleTimePosUpdate(false) p.invokeOnSongChangeCallbacks() p.invokeNoArgCallbacks(p.onStopped) p.alreadyScrobbled = false @@ -646,32 +655,23 @@ func (p *playbackEngine) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { } } -func (p *playbackEngine) setNextTrackAfterQueueUpdate() { - switch p.loopMode { - case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else { - // need to erase next track - p.setNextTrack(-1) - } - case LoopOne: - p.setNextTrack(p.nowPlayingIdx) - case LoopAll: - if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else { - p.setNextTrack(p.nowPlayingIdx + 1) - } - } -} - func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { if urlP, ok := p.player.(player.URLPlayer); ok { url := "" var meta mediaprovider.MediaItemMetadata if idx >= 0 { - url = p.getMediaURLForIdx(idx) + item := p.playQueue[idx] + track, isTrack := item.(*mediaprovider.Track) + meta = item.Metadata() + if isTrack && p.audiocache != nil { + if filepath := p.audiocache.PathForCachedFile(track.ID); filepath != "" { + url = filepath + log.Println("playing file from cache") + } + } + if url == "" { + url = p.getMediaURLForIdx(idx) + } if url == "" { return errors.New("no stream URL") } @@ -815,7 +815,7 @@ func (p *playbackEngine) startPollTimePos() { pollingTick.Stop() return case <-pollingTick.C: - p.doUpdateTimePos(false) + p.handleTimePosUpdate(false) } } }() @@ -828,11 +828,20 @@ func (p *playbackEngine) stopPollTimePos() { } } -func (p *playbackEngine) doUpdateTimePos(seeked bool) { +func (p *playbackEngine) handleTimePosUpdate(seeked bool) { + s := p.PlaybackStatus() + var meta mediaprovider.MediaItemMetadata + if np := p.NowPlaying(); np != nil { + meta = np.Metadata() + } + isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > float64(meta.Duration)-10 + if p.needToSetNextTrack && isNearEnd { + p.needToSetNextTrack = false + p.setNextTrackBasedOnLoopMode(false) + } if p.callbacksDisabled { return } - s := p.PlaybackStatus() if s.TimePos > p.latestTrackPosition { p.latestTrackPosition = s.TimePos } @@ -845,17 +854,3 @@ func (p *playbackEngine) doUpdateTimePos(seeked bool) { cb(s.TimePos, duration, seeked) } } - -func minInt(a, b int) int { - if a < b { - return a - } - return b -} - -func maxInt(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index bbd9e2b..60aaf23 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -559,7 +559,13 @@ func (d *DLNAPlayer) handleRequest(w http.ResponseWriter, r *http.Request) { return } - // Create a new request to the target server + // if the url is a filepath for a local cached file, serve it + if info, err := os.Stat(url); err == nil && info.Size() > 0 { + http.ServeFile(w, r, url) + return + } + + // Otherwise, proxy request to the music server proxyReq, err := http.NewRequest(r.Method, url, r.Body) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) From ae7cb55c8c8be2302d7615577338a35c4d9d5c19 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 23 Jul 2025 22:10:33 -0700 Subject: [PATCH 04/23] very WIP - proof of concept for waveform seekbar --- backend/playbackengine.go | 6 +- backend/playbackmanager.go | 46 +++++++- backend/waveformimage.go | 235 +++++++++++++++++++++++++++++++++++++ go.mod | 5 + go.sum | 15 ++- 5 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 backend/waveformimage.go diff --git a/backend/playbackengine.go b/backend/playbackengine.go index c2cc0ea..344a491 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -582,7 +582,11 @@ func (p *playbackEngine) cacheNextTracks() { } } } - p.audiocache.CacheOnly(p.NowPlaying().Metadata().ID, fetch) + id := "" + if np := p.NowPlaying(); np != nil { + id = np.Metadata().ID + } + p.audiocache.CacheOnly(id, fetch) } } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index c331747..76d9ae1 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -3,6 +3,7 @@ package backend import ( "context" "fmt" + "image/color" "log" "math/rand" "runtime" @@ -10,6 +11,9 @@ import ( "sync" "time" + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/theme" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/dlna" @@ -23,6 +27,7 @@ import ( // intermediary between the frontend and various Player backends. type PlaybackManager struct { engine *playbackEngine + cache *AudioCache cmdQueue *playbackCommandQueue cfg *AppConfig @@ -61,6 +66,7 @@ func NewPlaybackManager( cfg: appCfg, autoplay: playbackCfg.Autoplay, localPlayer: p, + cache: c, } pm.addOnTrackChangeHook() go pm.runCmdQueue(ctx) @@ -76,12 +82,50 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) - p.OnSongChange(func(mediaprovider.MediaItem, *mediaprovider.Track) { + p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { // Autoplay if enabled and we are on the last track if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } + // TODO: make more permanent + if p.cache != nil && item != nil { + go func() { + log.Println("begin generating waveform image") + if path := p.cache.PathForCachedFile(item.Metadata().ID); path != "" { + t := time.Now() + wd, err := GetWaveformDataForFile(context.Background(), path) + log.Printf("generate data took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) + if err != nil { + log.Println(err.Error()) + } else { + log.Println("have waveform data") + im := NewWaveformImage() + var c color.Color + fyne.DoAndWait(func() { + c = fyne.CurrentApp().Settings().Theme().Color( + theme.ColorNamePrimary, + fyne.CurrentApp().Settings().ThemeVariant(), + ) + }) + log.Println("generating image...") + t := time.Now() + GenerateWaveformImage(wd, im, c) + log.Printf("generate image took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) + fyne.Do(func() { + w := fyne.CurrentApp().NewWindow("waveform") + w.SetPadded(false) + w.SetContent(canvas.NewImageFromImage(im)) + w.Resize(fyne.NewSize(1024, 32)) + w.Show() + }) + } + } else { + log.Println("no cached file for waveform image") + } + }() + } + if runtime.GOOS != "windows" { return } diff --git a/backend/waveformimage.go b/backend/waveformimage.go new file mode 100644 index 0000000..52acb90 --- /dev/null +++ b/backend/waveformimage.go @@ -0,0 +1,235 @@ +package backend + +import ( + "context" + "errors" + "image" + "image/color" + "io" + "math" + "os" + "path/filepath" + + "github.com/go-audio/audio" + "github.com/go-audio/wav" + "github.com/supersonic-app/go-mpv" +) + +type WaveformData struct { + Peak [1024]byte + RMS [1024]byte +} + +type WaveformImage = image.NRGBA + +func NewWaveformImage() *WaveformImage { + return image.NewNRGBA(image.Rect(0, 0, 1024, 32)) +} + +func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Color) { + centerY := imgbuf.Rect.Dy() / 2 // 16 + top := centerY - 1 + bottom := centerY + + // Convert the input color to RGBA + r, g, b, _ := c.RGBA() + opaqueColor := color.NRGBA{ + R: uint8(r >> 8), + G: uint8(g >> 8), + B: uint8(b >> 8), + A: 255, + } + translucentColor := color.NRGBA{ + R: uint8(r >> 8), + G: uint8(g >> 8), + B: uint8(b >> 8), + A: 128, // 50% opacity + } + + for x := 0; x < 1024; x++ { + rms := float64(data.RMS[x]) / 255.0 + peak := float64(data.Peak[x]) / 255.0 + + rmsPixels := int(rms * 16) + peakPixels := int((peak - rms) * 16) + + // Always draw at least 2 center pixels + setPixel(imgbuf, x, top, opaqueColor) + setPixel(imgbuf, x, bottom, opaqueColor) + + // Draw RMS pixels (solid) + for i := 1; i <= rmsPixels; i++ { + setPixel(imgbuf, x, top-i, opaqueColor) + setPixel(imgbuf, x, bottom+i, opaqueColor) + } + + // Draw Peak extension (translucent) + for i := 1; i <= peakPixels; i++ { + setPixel(imgbuf, x, top-rmsPixels-i, translucentColor) + setPixel(imgbuf, x, bottom+rmsPixels+i, translucentColor) + } + } +} + +func GetWaveformDataForFile(ctx context.Context, fpath string) (*WaveformData, error) { + dir := filepath.Dir(fpath) + transcodeFile := filepath.Join(dir, filepath.Base(fpath)+"_waveform.wav") + + err := convertToWav(ctx, fpath, transcodeFile) + if err != nil { + return nil, err + } + + f, err := os.Open(transcodeFile) + if err != nil { + return nil, err + } + defer f.Close() + defer os.Remove(transcodeFile) + + decoder := wav.NewDecoder(f) + if !decoder.IsValidFile() { + return nil, errors.New("invalid wav file") + } + + dur, err := decoder.Duration() + if err != nil { + return nil, err + } + + format := decoder.Format() + + totalSamples := format.SampleRate * int(dur.Milliseconds()) / 1000 + samplesPerChunk := totalSamples / 1024 + + if err := decoder.FwdToPCM(); err != nil { + return nil, err + } + + buf := &audio.IntBuffer{Data: make([]int, 4096)} + data := &WaveformData{} + curChunk := 0 + chunkSamples := make([]float32, 0, samplesPerChunk) + for { + n, err := decoder.PCMBuffer(buf) + if n == 0 || err == io.EOF { + break + } + if err != nil { + return data, err + } + + // Process samples + for i := 0; i < n; i += format.NumChannels { + sum := 0 + for c := 0; c < format.NumChannels; c++ { + sum += buf.Data[i+c] + } + avg := float64(sum) / float64(format.NumChannels) + // TODO: this assumes 16 bit + sample := float32(avg / float64(1<<15)) // Normalize to [-1, 1] + chunkSamples = append(chunkSamples, sample) + + if len(chunkSamples) >= samplesPerChunk { + if curChunk < 1024 { + peak, rms := computePeakAndRMS(chunkSamples) + data.Peak[curChunk] = float32ToByte(peak) + data.RMS[curChunk] = float32ToByte(rms) + } + curChunk++ + chunkSamples = chunkSamples[:0] + if curChunk >= 1024 { + break + } + } + } + } + + // Optionally fill the last chunk if it's partially filled + if curChunk < 1024 && len(chunkSamples) > 0 { + peak, rms := computePeakAndRMS(chunkSamples) + data.Peak[curChunk] = float32ToByte(peak) + data.RMS[curChunk] = float32ToByte(rms) + } + + return data, nil +} + +func computePeakAndRMS(chunk []float32) (peak float32, rms float32) { + var sumSquares float64 + peak = 0.0 + for _, v := range chunk { + abs := float32(math.Abs(float64(v))) + if abs > peak { + peak = abs + } + sumSquares += float64(v * v) + } + rms = float32(math.Sqrt(sumSquares / float64(len(chunk)))) + return +} + +func float32ToByte(val float32) byte { + if val > 1.0 { + val = 1.0 + } + if val < 0.0 { + val = 0.0 + } + return byte(val * 255) +} + +func convertToWav(ctx context.Context, inPath, outPath string) error { + m := mpv.Create() + m.SetOptionString("video", "no") + m.SetOptionString("audio-display", "no") + m.SetOptionString("terminal", "no") + m.SetOptionString("idle", "yes") + m.SetOptionString("ao-pcm-file", outPath) + m.SetOptionString("ao", "pcm") + m.SetOption("volume", mpv.FORMAT_INT64, 100) + // no need to preserve full sample resolution just for waveform image + // let's make less data to process and smaller on-disk file + m.SetOption("audio-samplerate", mpv.FORMAT_INT64, 22050) + m.SetOptionString("audio-channels", "mono") + m.SetOptionString("audio-format", "s16") + if err := m.Initialize(); err != nil { + return err + } + + m.Command([]string{"loadfile", inPath, "replace"}) + + //log.Println("generating wav file from %s using MPV", inPath) + return mpvWaitForIdle(ctx, m) +} + +func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { + if x < 0 || x >= img.Bounds().Dx() || y < 0 || y >= img.Bounds().Dy() { + return + } + offset := img.PixOffset(x, y) + img.Pix[offset+0] = c.R + img.Pix[offset+1] = c.G + img.Pix[offset+2] = c.B + img.Pix[offset+3] = c.A +} + +func mpvWaitForIdle(ctx context.Context, m *mpv.Mpv) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + ia := m.GetPropertyString("idle-active") + if ia == "yes" || ia == "true" { + return nil + } + // use small timeout to allow detecting ctx expiry + // without too much delay + e := m.WaitEvent(0.1 /*timeout seconds*/) + if e.Event_Id == mpv.EVENT_IDLE { + return nil + } + } + } +} diff --git a/go.mod b/go.mod index 13ba0ec..f26226d 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,8 @@ require ( github.com/dweymouth/fyne-advanced-list v0.0.0-20250211191927-58ea85eec72c github.com/dweymouth/fyne-tooltip v0.3.0 github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f + github.com/go-audio/audio v1.0.0 + github.com/go-audio/wav v1.1.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -39,6 +41,7 @@ require ( github.com/fyne-io/glfw-js v0.2.0 // indirect github.com/fyne-io/image v0.1.1 // indirect github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-audio/riff v1.0.0 // indirect github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect @@ -64,3 +67,5 @@ require ( ) replace fyne.io/fyne/v2 v2.6.1 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4 + +replace github.com/go-audio/wav v1.1.0 => github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0 diff --git a/go.sum b/go.sum index 2ae2ab4..fbd0355 100644 --- a/go.sum +++ b/go.sum @@ -23,12 +23,10 @@ github.com/dweymouth/fyne-tooltip v0.3.0 h1:NKCyTkh9NtvnTsiHtTOtaJzRDOFYP8AckQ2t github.com/dweymouth/fyne-tooltip v0.3.0/go.mod h1:m04ShLW/Tp6LXrNieTumApvNgo7YSB+wi+jZTN+kDBU= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4 h1:Q3r94AcVL8yaF4Nrd3EQFKyLL3UN/zHPkj+My9B2vCQ= github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20250712002006-5064d705dac4/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU= -github.com/dweymouth/go-jellyfin v0.0.0-20250531151636-29591764f0a0 h1:9t1CR83uzn5Va4Nycwncmuw/NEAN64O4lf82VBPzdfE= -github.com/dweymouth/go-jellyfin v0.0.0-20250531151636-29591764f0a0/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= -github.com/dweymouth/go-jellyfin v0.0.0-20250715154414-d0a1630ee74f h1:7O7Cn17pwKHG7zPgNhxMabXJP1ZdmNVxCb4i8yJgVZo= -github.com/dweymouth/go-jellyfin v0.0.0-20250715154414-d0a1630ee74f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f h1:QsKPwFpTHuYHEEuhvp4VBClkHh00bNNgQ/2Ij1bkk8M= github.com/dweymouth/go-jellyfin v0.0.0-20250716005557-0ea2becece1f/go.mod h1:fcUagHBaQnt06GmBAllNE0J4O/7064zXRWdqnTTtVjI= +github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0 h1:mYcctuWgVArHhSLJxndlUM43C3hoE18BLDBkXKM2tl0= +github.com/dweymouth/go-wav v0.0.0-20250719173115-e60429a83eb0/go.mod h1:bp2870jtp/ixAJLIOdShBfl1WpyLGDZ57jnVWMgkgIc= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= @@ -47,6 +45,14 @@ github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-audio/aiff v0.0.0-20180403003018-6c3a8a6aff12/go.mod h1:AMSAp6W1zd0koOdX6QDgGIuBDTUvLa2SLQtm7d9eM3c= +github.com/go-audio/aiff v1.0.0/go.mod h1:Kazp+9JR/Y1ITCXaDlO6OIIOrz6eGGAn+dGT04V4HPM= +github.com/go-audio/audio v0.0.0-20180206231410-b697a35b5608/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v0.0.0-20181013172942-de841e69b884/go.mod h1:UiqzUyfX0zs3pJ/DPyvS5v8sN6s5bXPUDDIVA5v8dks= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= @@ -87,6 +93,7 @@ github.com/koron/go-ssdp v0.0.5 h1:E1iSMxIs4WqxTbIBLtmNBeOOC+1sCIXQeqTWVnpmwhk= github.com/koron/go-ssdp v0.0.5/go.mod h1:Qm59B7hpKpDqfyRNWRNr00jGwLdXjDyZh6y7rH6VS0w= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattetti/audio v0.0.0-20180912171649-01576cde1f21/go.mod h1:LlQmBGkOuV/SKzEDXBPKauvN2UqCgzXO2XjecTGj40s= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= From 43f6d501c91ea3aa29797a8bff284102e47e2372 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 24 Jul 2025 08:51:25 -0700 Subject: [PATCH 05/23] create waveform seekbar widget; still more to do --- backend/playbackmanager.go | 35 ++++------- backend/waveformimage.go | 3 - ui/bottompanel.go | 7 +++ ui/widgets/playercontrols.go | 15 ++++- ui/widgets/waveformseekbar.go | 109 ++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 29 deletions(-) create mode 100644 ui/widgets/waveformseekbar.go diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 76d9ae1..21abcd9 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -11,9 +11,6 @@ import ( "sync" "time" - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/canvas" - "fyne.io/fyne/v2/theme" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/player" "github.com/dweymouth/supersonic/backend/player/dlna" @@ -36,6 +33,8 @@ type PlaybackManager struct { remotePlayers []RemotePlaybackDevice currentRemotePlayer *RemotePlaybackDevice + onWaveformImgUpdate []func(*WaveformImage) + autoplay bool lastPlayTime float64 @@ -91,34 +90,16 @@ func (p *PlaybackManager) addOnTrackChangeHook() { // TODO: make more permanent if p.cache != nil && item != nil { go func() { - log.Println("begin generating waveform image") if path := p.cache.PathForCachedFile(item.Metadata().ID); path != "" { - t := time.Now() wd, err := GetWaveformDataForFile(context.Background(), path) - log.Printf("generate data took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) if err != nil { log.Println(err.Error()) } else { - log.Println("have waveform data") im := NewWaveformImage() - var c color.Color - fyne.DoAndWait(func() { - c = fyne.CurrentApp().Settings().Theme().Color( - theme.ColorNamePrimary, - fyne.CurrentApp().Settings().ThemeVariant(), - ) - }) - log.Println("generating image...") - t := time.Now() - GenerateWaveformImage(wd, im, c) - log.Printf("generate image took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000) - fyne.Do(func() { - w := fyne.CurrentApp().NewWindow("waveform") - w.SetPadded(false) - w.SetContent(canvas.NewImageFromImage(im)) - w.Resize(fyne.NewSize(1024, 32)) - w.Show() - }) + GenerateWaveformImage(wd, im, color.White) + for _, cb := range p.onWaveformImgUpdate { + cb(im) + } } } else { log.Println("no cached file for waveform image") @@ -213,6 +194,10 @@ func (p *PlaybackManager) OnPlayerChange(cb func()) { p.engine.onPlayerChange = append(p.engine.onPlayerChange, cb) } +func (p *PlaybackManager) OnWaveformImgUpdate(cb func(*WaveformImage)) { + p.onWaveformImgUpdate = append(p.onWaveformImgUpdate, cb) +} + func (p *PlaybackManager) IsSeeking() bool { return p.engine.IsSeeking() } diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 52acb90..201f4a3 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -204,9 +204,6 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { } func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { - if x < 0 || x >= img.Bounds().Dx() || y < 0 || y >= img.Bounds().Dy() { - return - } offset := img.PixOffset(x, y) img.Pix[offset+0] = c.R img.Pix[offset+1] = c.G diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 60e0ae4..1359d05 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -33,6 +33,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr bp.ExtendBaseWidget(bp) pm.OnSongChange(bp.onSongChange) + pm.OnWaveformImgUpdate(bp.updateWaveformImg) pm.OnPlayTimeUpdate(func(cur, total float64, _ bool) { fyne.Do(func() { if !pm.IsSeeking() { @@ -141,6 +142,12 @@ func (bp *BottomPanel) onSongChange(song mediaprovider.MediaItem, _ *mediaprovid }) } +func (bp *BottomPanel) updateWaveformImg(img *backend.WaveformImage) { + fyne.Do(func() { + bp.Controls.UpdateWaveformImg(img) + }) +} + func (bp *BottomPanel) CreateRenderer() fyne.WidgetRenderer { bp.ExtendBaseWidget(bp) return widget.NewSimpleRenderer(bp.container) diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index 619aece..d3a286e 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -1,6 +1,7 @@ package widgets import ( + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/ui/util" "fyne.io/fyne/v2" @@ -82,6 +83,7 @@ type PlayerControls struct { widget.BaseWidget slider *TrackPosSlider + waveform *WaveformSeekbar curTimeLabel *labelMinSize totalTimeLabel *labelMinSize prev *IconButton @@ -116,6 +118,8 @@ func NewPlayerControls() *PlayerControls { pc.slider = NewTrackPosSlider() pc.slider.Disable() + pc.slider.Hide() + pc.waveform = NewWaveformSeekbar() pc.curTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) pc.curTimeLabel.Alignment = fyne.TextAlignTrailing pc.totalTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) @@ -138,7 +142,11 @@ func NewPlayerControls() *PlayerControls { buttons := container.NewHBox(layout.NewSpacer(), pc.prev, pc.playpause, pc.next, layout.NewSpacer()) - c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, pc.slider) + seekCtrl := container.NewStack( + pc.slider, + pc.waveform, + ) + c := container.NewBorder(nil, nil, pc.curTimeLabel, pc.totalTimeLabel, seekCtrl) pc.container = container.New(layout.NewCustomPaddedVBoxLayout(0), c, buttons) return pc @@ -203,10 +211,15 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { if updated { // Only update slider once a second when time label changes pc.slider.SetValue(v) + pc.waveform.SetProgress(v) } } } +func (p *PlayerControls) UpdateWaveformImg(img *backend.WaveformImage) { + p.waveform.UpdateImage(img) +} + func (p *PlayerControls) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(p.container) } diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go new file mode 100644 index 0000000..9c2b81d --- /dev/null +++ b/ui/widgets/waveformseekbar.go @@ -0,0 +1,109 @@ +package widgets + +import ( + "image" + "image/color" + "log" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" +) + +type WaveformSeekbar struct { + widget.BaseWidget + + imgColorL color.Color + imgColorR color.Color + imgProgress float64 + + img *canvas.Image +} + +func NewWaveformSeekbar() *WaveformSeekbar { + w := &WaveformSeekbar{ + img: canvas.NewImageFromImage(nil), + } + w.ExtendBaseWidget(w) + return w +} + +func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { + w.img.Image = img + w.Refresh() +} + +func (w *WaveformSeekbar) Refresh() { + prm, fg := w.getThemeColors() + w.recolorImage(prm, fg, w.imgProgress) + w.img.Refresh() + //w.BaseWidget.Refresh() +} + +func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer( + container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), + ) +} + +// SetProgress sets how much of the seekbar has been played +// (ratio from 0 to 1) +func (w *WaveformSeekbar) SetProgress(v float64) { + prm, fg := w.getThemeColors() + w.recolorImage(prm, fg, v) + w.Refresh() +} + +func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { + th := w.Theme() + vnt := fyne.CurrentApp().Settings().ThemeVariant() + primary = th.Color(theme.ColorNamePrimary, vnt) + foreground = th.Color(theme.ColorNameForeground, vnt) + return primary, foreground +} + +func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress float64) { + if w.img.Image == nil { + return + } + if w.imgColorL == cL && w.imgColorR == cR && w.imgProgress == progress { + return + } + + _r, _g, _b, _ := cL.RGBA() + rL, gL, bL := byte(_r>>8), byte(_g>>8), byte(_b>>8) + _r, _g, _b, _ = cR.RGBA() + rR, gR, bR := byte(_r>>8), byte(_g>>8), byte(_b>>8) + + // TODO- smartly figure out which pixels we need + // to update for different scenarios (e.g. progress update only) + // and not iterate the whole thing every time + img := w.img.Image.(*image.NRGBA) + bnds := img.Rect.Bounds() + thresholdPixel := int(float64(bnds.Dx()) * progress) + log.Println("progress = ", progress, " bounds Dx = ", bnds.Dx()) + log.Println("thresholdPixel = ", thresholdPixel) + for x := 0; x < bnds.Dx(); x++ { + for y := 0; y < bnds.Dy(); y++ { + if x < thresholdPixel { + setPixelRGB(img, x, y, rL, gL, bL) + } else { + setPixelRGB(img, x, y, rR, gR, bR) + } + } + } + + w.imgColorL, w.imgColorR = cL, cR + w.imgProgress = progress +} + +func setPixelRGB(img *image.NRGBA, x, y int, r, g, b byte) { + offset := img.PixOffset(x, y) + img.Pix[offset+0] = r + img.Pix[offset+1] = g + img.Pix[offset+2] = b +} From 7dae735d10ff53dccb67223e7d0c509dece2b262 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 24 Jul 2025 19:26:54 -0700 Subject: [PATCH 06/23] seeking, layout update --- backend/playbackengine.go | 2 +- ui/bottompanel.go | 2 +- ui/browsing/browsingpane.go | 2 +- ui/layouts/leftmiddlerightlayout.go | 30 ++++++----- ui/widgets/playercontrols.go | 3 +- ui/widgets/waveformseekbar.go | 81 +++++++++++++++++++++-------- 6 files changed, 82 insertions(+), 38 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 344a491..cd7a463 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -810,7 +810,7 @@ func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) { func (p *playbackEngine) startPollTimePos() { ctx, cancel := context.WithCancel(p.ctx) p.cancelPollPos = cancel - pollingTick := time.NewTicker(250 * time.Millisecond) + pollingTick := time.NewTicker(125 * time.Millisecond) go func() { for { diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 1359d05..699f284 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -125,7 +125,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr bp.imageLoader = util.NewThumbnailLoader(im, bp.NowPlaying.SetImage) - bp.container = container.New(layouts.NewLeftMiddleRightLayout(500), + bp.container = container.New(layouts.NewLeftMiddleRightLayout(300, 0.4), bp.NowPlaying, bp.Controls, bp.AuxControls) return bp } diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index e666ae7..deecf88 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -109,7 +109,7 @@ func NewBrowsingPane(app *backend.App, contr *controller.Controller, onGoHome fu b.navBtnsPageMap = map[controller.PageName]fyne.Resource{} b.container = container.NewBorder(container.New( &layout.CustomPaddedLayout{LeftPadding: -5, RightPadding: -5}, - container.New(layouts.NewLeftMiddleRightLayout(0), + container.New(layouts.NewLeftMiddleRightLayout(0, 0), container.NewHBox(b.home, b.back, b.forward, b.reload), b.navBtnsContainer, container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))), nil, nil, nil, b.pageContainer) diff --git a/ui/layouts/leftmiddlerightlayout.go b/ui/layouts/leftmiddlerightlayout.go index 13bbe22..f4433ad 100644 --- a/ui/layouts/leftmiddlerightlayout.go +++ b/ui/layouts/leftmiddlerightlayout.go @@ -7,17 +7,20 @@ import ( ) // Lays out up to 3 objects such that the middle object, Objects[1], -// is centered in the available space and takes up a fixed width. -// The left, and right (if non-nil), split the leftover space equally. +// is centered in the available space and takes up a fixed width, +// or optionally a fraction of the overall space. +// The left, and right split the leftover space equally. type LeftMiddleRightLayout struct { - middleWidth float32 - hbox fyne.Layout + middleWidthMin float32 + middleWidthFraction float32 + hbox fyne.Layout } -func NewLeftMiddleRightLayout(middleWidth float32) *LeftMiddleRightLayout { +func NewLeftMiddleRightLayout(middleWidthMin, middleWidthFraction float32) *LeftMiddleRightLayout { return &LeftMiddleRightLayout{ - middleWidth: middleWidth, - hbox: layout.NewHBoxLayout(), + middleWidthMin: middleWidthMin, + middleWidthFraction: middleWidthFraction, + hbox: layout.NewHBoxLayout(), } } @@ -25,20 +28,21 @@ func (b *LeftMiddleRightLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { hboxSize := b.hbox.MinSize(objects) return fyne.Size{ Height: hboxSize.Height, - Width: hboxSize.Width + fyne.Max(0, b.middleWidth-objects[1].MinSize().Width), + Width: hboxSize.Width + fyne.Max(0, b.middleWidthMin-objects[1].MinSize().Width), } } func (b *LeftMiddleRightLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { pad := theme.Padding() - midW := fyne.Max(b.middleWidth, objects[1].MinSize().Width) + lrMinWidth := fyne.Max(objects[0].MinSize().Width, objects[2].MinSize().Width) + midMinWidth := fyne.Max(b.middleWidthMin, objects[1].MinSize().Width) + midMaxWidth := size.Width - lrMinWidth*2 - pad*4 + midW := fyne.Min(midMaxWidth, fyne.Max(midMinWidth, b.middleWidthFraction*size.Width)) lrW := (size.Width - midW - pad*4) / 2 objects[0].Resize(fyne.NewSize(lrW, size.Height)) objects[0].Move(fyne.NewPos(pad, 0)) objects[1].Resize(fyne.NewSize(midW, size.Height)) objects[1].Move(fyne.NewPos(lrW+pad*2, 0)) - if objects[2] != nil { - objects[2].Resize(fyne.NewSize(lrW, size.Height)) - objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0)) - } + objects[2].Resize(fyne.NewSize(lrW, size.Height)) + objects[2].Move(fyne.NewPos(lrW+midW+pad*3, 0)) } diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index d3a286e..216bd26 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -160,6 +160,7 @@ func (pc *PlayerControls) OnSeek(f func(float64)) { f(pos) } } + pc.waveform.OnSeeked = f } func (pc *PlayerControls) OnSeekPrevious(f func()) { @@ -208,10 +209,10 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { pc.curTimeLabel.SetText(ct) updated = true } + pc.waveform.SetProgress(v) if updated { // Only update slider once a second when time label changes pc.slider.SetValue(v) - pc.waveform.SetProgress(v) } } } diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 9c2b81d..52bd07f 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -3,50 +3,89 @@ package widgets import ( "image" "image/color" - "log" + "math" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend" + myTheme "github.com/dweymouth/supersonic/ui/theme" ) type WaveformSeekbar struct { widget.BaseWidget - imgColorL color.Color - imgColorR color.Color - imgProgress float64 + OnSeeked func(float64) - img *canvas.Image + imgColorL color.Color + imgColorR color.Color + imgProgressPixel int + + img *canvas.Image + cursor *myTheme.ThemedRectangle } func NewWaveformSeekbar() *WaveformSeekbar { w := &WaveformSeekbar{ - img: canvas.NewImageFromImage(nil), + img: &canvas.Image{ + ScaleMode: canvas.ImageScaleFastest, + }, + cursor: myTheme.NewThemedRectangle(theme.ColorNameForeground), } + w.cursor.Hide() w.ExtendBaseWidget(w) return w } func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { w.img.Image = img + prm, fg := w.getThemeColors() + w.recolorImage(prm, fg, w.imgProgressPixel) w.Refresh() } func (w *WaveformSeekbar) Refresh() { + w.cursor.Resize(fyne.NewSize(1.5, w.Size().Height-4)) + w.cursor.Refresh() prm, fg := w.getThemeColors() - w.recolorImage(prm, fg, w.imgProgress) - w.img.Refresh() - //w.BaseWidget.Refresh() + if w.recolorImage(prm, fg, w.imgProgressPixel) { + w.img.Refresh() + } +} + +var _ desktop.Hoverable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { + w.cursor.Move(fyne.NewPos(e.Position.X, 2)) + w.cursor.Show() +} + +func (w *WaveformSeekbar) MouseMoved(e *desktop.MouseEvent) { + w.cursor.Move(fyne.NewPos(e.Position.X, 2)) +} + +func (w *WaveformSeekbar) MouseOut() { + w.cursor.Hide() +} + +var _ fyne.Tappable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) Tapped(e *fyne.PointEvent) { + if w.OnSeeked != nil { + w.OnSeeked(float64(e.Position.X / w.Size().Width)) + } } func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer( - container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), + container.NewStack( + container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), + container.NewWithoutLayout(w.cursor), + ), ) } @@ -54,8 +93,10 @@ func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { // (ratio from 0 to 1) func (w *WaveformSeekbar) SetProgress(v float64) { prm, fg := w.getThemeColors() - w.recolorImage(prm, fg, v) - w.Refresh() + thresholdPixel := int(math.Round(1024.0 /*pixel width of waveform*/ * v)) + if w.recolorImage(prm, fg, thresholdPixel) { + w.img.Refresh() + } } func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { @@ -66,12 +107,12 @@ func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { return primary, foreground } -func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress float64) { +func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress int) (updated bool) { if w.img.Image == nil { - return + return false } - if w.imgColorL == cL && w.imgColorR == cR && w.imgProgress == progress { - return + if w.imgColorL == cL && w.imgColorR == cR && w.imgProgressPixel == progress { + return false } _r, _g, _b, _ := cL.RGBA() @@ -84,12 +125,9 @@ func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress float64) { // and not iterate the whole thing every time img := w.img.Image.(*image.NRGBA) bnds := img.Rect.Bounds() - thresholdPixel := int(float64(bnds.Dx()) * progress) - log.Println("progress = ", progress, " bounds Dx = ", bnds.Dx()) - log.Println("thresholdPixel = ", thresholdPixel) for x := 0; x < bnds.Dx(); x++ { for y := 0; y < bnds.Dy(); y++ { - if x < thresholdPixel { + if x < progress { setPixelRGB(img, x, y, rL, gL, bL) } else { setPixelRGB(img, x, y, rR, gR, bR) @@ -98,7 +136,8 @@ func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress float64) { } w.imgColorL, w.imgColorR = cL, cR - w.imgProgress = progress + w.imgProgressPixel = progress + return true } func setPixelRGB(img *image.NRGBA, x, y int, r, g, b byte) { From 88b7f62e5008f4d4bae5439a8e9ba98318cce19d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 25 Jul 2025 18:38:59 -0700 Subject: [PATCH 07/23] some progress in generating waveform images ahead of time --- backend/audiocache.go | 10 ++++ backend/playbackengine.go | 87 ++++++++++++++------------- backend/playbackmanager.go | 81 ++++++++++++++++++++------ backend/util/filestreamer.go | 110 +++++++++++++++++++++++++++++++++++ backend/waveformimage.go | 16 ++++- 5 files changed, 242 insertions(+), 62 deletions(-) create mode 100644 backend/util/filestreamer.go diff --git a/backend/audiocache.go b/backend/audiocache.go index 965d14d..ee9141d 100644 --- a/backend/audiocache.go +++ b/backend/audiocache.go @@ -59,6 +59,16 @@ func (a *AudioCache) PathForCachedFile(id string) string { return "" } +// PathForCachedFile returns the local filesystem path for a cached track, +// including one that is in the process of downloading. +// If it is not cached or downloading, it returns an empty string. +func (a *AudioCache) PathForCachedOrDownloadingFile(id string) string { + if _, ok := a.entries[id]; ok { + return a.pathForID(id) + } + return "" +} + // cacheFile begins downloading a file (if not already downloading) and stores it // to the cache directory under its ID as filename. The download is asynchronous. func (a *AudioCache) cacheFile(id, dlURL string) { diff --git a/backend/playbackengine.go b/backend/playbackengine.go index cd7a463..d1123ed 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -89,16 +89,17 @@ type playbackEngine struct { replayGainCfg ReplayGainConfig // registered callbacks - onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) - onPlayTimeUpdate []func(float64, float64, bool) - onLoopModeChange []func(LoopMode) - onVolumeChange []func(int) - onSeek []func() - onPaused []func() - onStopped []func() - onPlaying []func() - onPlayerChange []func() - onQueueChange []func() + onBeforeSongChange []func(next mediaprovider.MediaItem) + onSongChange []func(nowPlaying mediaprovider.MediaItem, justScrobbledIfAny *mediaprovider.Track) + onPlayTimeUpdate []func(float64, float64, bool) + onLoopModeChange []func(LoopMode) + onVolumeChange []func(int) + onSeek []func() + onPaused []func() + onStopped []func() + onPlaying []func() + onPlayerChange []func() + onQueueChange []func() } func NewPlaybackEngine( @@ -246,8 +247,7 @@ func (p *playbackEngine) SetLoopMode(loopMode LoopMode) { if p.nowPlayingIdx >= 0 { // TODO - don't need when going from LoopNone to LoopAll // if not on last track - p.needToSetNextTrack = true - //p.setNextTrackBasedOnLoopMode(true) + p.handleNextTrackUpdated() } for _, cb := range p.onLoopModeChange { @@ -368,7 +368,9 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu p.nowPlayingIdx = -1 p.playQueue = nil } - p.needToSetNextTrack = len(items) > 0 && (insertQueueMode == InsertNext || (insertQueueMode == Append && p.nowPlayingIdx == len(p.playQueue)-1)) + if nextChanged := len(items) > 0 && (insertQueueMode != Append || (p.nowPlayingIdx == len(p.playQueue)-1)); nextChanged { + defer p.handleNextTrackUpdated() + } if shuffle { rand.Shuffle(len(items), func(i, j int) { items[i], items[j] = items[j], items[i] }) @@ -380,10 +382,6 @@ func (p *playbackEngine) doLoaditems(items []mediaprovider.MediaItem, insertQueu } p.playQueue = append(p.playQueue[:insertIdx], append(items, p.playQueue[insertIdx:]...)...) - //if needToSetNext { - // p.setNextTrack(p.nowPlayingIdx + 1) - //} - p.invokeNoArgCallbacks(p.onQueueChange) return nil } @@ -394,7 +392,9 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins p.nowPlayingIdx = -1 p.playQueue = nil } - p.needToSetNextTrack = insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1) + if nextChanged := insertMode == InsertNext || (insertMode == Append && p.nowPlayingIdx == len(p.playQueue)-1); nextChanged { + p.handleNextTrackUpdated() + } insertIdx := len(p.playQueue) if insertMode == InsertNext { insertIdx = p.nowPlayingIdx + 1 @@ -406,10 +406,6 @@ func (p *playbackEngine) LoadRadioStation(radio *mediaprovider.RadioStation, ins copy(new[len(firstHalf)+1:], p.playQueue[insertIdx:]) p.playQueue = new - //if needToSetNext { - // p.setNextTrack(p.nowPlayingIdx + 1) - //} - p.invokeNoArgCallbacks(p.onQueueChange) } @@ -468,11 +464,10 @@ func (p *playbackEngine) UpdatePlayQueue(items []mediaprovider.MediaItem) error if p.nowPlayingIdx >= 0 && newNowPlayingIdx == -1 { return p.Stop() } - p.needToSetNextTrack = p.nowPlayingIdx >= 0 + if p.nowPlayingIdx >= 0 { + p.handleNextTrackUpdated() + } p.nowPlayingIdx = newNowPlayingIdx - //if needToUpdateNext { - // p.setNextTrackBasedOnLoopMode(false) - //} p.invokeNoArgCallbacks(p.onQueueChange) return nil @@ -518,8 +513,7 @@ func (p *playbackEngine) RemoveTracksFromQueue(idxs []int) { // when we receive new track event from player } else if isNextPlayingTrackremoved { if newNowPlaying < len(newQueue)-1 { - p.needToSetNextTrack = true - //p.setNextTrack(p.nowPlayingIdx + 1) + p.handleNextTrackUpdated() } else { // no next track to play p.setNextTrack(-1) @@ -620,8 +614,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.handleTimePosUpdate(false) - p.cacheNextTracks() - p.setNextTrackBasedOnLoopMode(false) + p.handleNextTrackUpdated() } func (p *playbackEngine) handleOnStopped() { @@ -638,25 +631,35 @@ func (p *playbackEngine) handleOnStopped() { p.nowPlayingIdx = -1 } -func (p *playbackEngine) setNextTrackBasedOnLoopMode(onLoopModeChange bool) { +// to be invoked as soon as the next item in the queue that should play changes +func (p *playbackEngine) handleNextTrackUpdated() { + p.cacheNextTracks() + p.needToSetNextTrack = true + for _, cb := range p.onBeforeSongChange { + var item mediaprovider.MediaItem + if idx := p.nextPlayingIndex(); idx >= 0 { + item = p.playQueue[idx] + } + cb(item) + } +} + +func (p *playbackEngine) nextPlayingIndex() int { switch p.loopMode { case LoopNone: - if p.nowPlayingIdx < len(p.playQueue)-1 { - p.setNextTrack(p.nowPlayingIdx + 1) - } else if onLoopModeChange { - // prev was LoopOne - need to erase next track - p.setNextTrack(-1) + if p.nowPlayingIdx >= len(p.playQueue)-1 { + return -1 } + return p.nowPlayingIdx + 1 case LoopOne: - p.setNextTrack(p.nowPlayingIdx) + return p.nowPlayingIdx case LoopAll: if p.nowPlayingIdx >= len(p.playQueue)-1 { - p.setNextTrack(0) - } else if !onLoopModeChange { - // if onloopmodechange, prev mode was LoopNone and next track is already set - p.setNextTrack(p.nowPlayingIdx + 1) + return 0 } + return p.nowPlayingIdx + 1 } + return -1 // unreached } func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { @@ -841,7 +844,7 @@ func (p *playbackEngine) handleTimePosUpdate(seeked bool) { isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > float64(meta.Duration)-10 if p.needToSetNextTrack && isNearEnd { p.needToSetNextTrack = false - p.setNextTrackBasedOnLoopMode(false) + p.setNextTrack(p.nextPlayingIndex()) } if p.callbacksDisabled { return diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 21abcd9..3b7c2fe 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -47,6 +47,13 @@ type RemotePlaybackDevice struct { new func() (player.BasePlayer, error) } +var zeroWaveformImage *WaveformImage + +func init() { + zeroWaveformImage = NewWaveformImage() + GenerateWaveformImage(&WaveformData{}, zeroWaveformImage, color.White) +} + func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -81,30 +88,68 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) + var nextWaveformImgLock sync.Mutex + var nextWaveformImg *WaveformImage + var nextWaveformImgID string + var cancel context.CancelFunc + + p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { + if p.cache != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + log.Println("preparing waveform image for next track ", item.Metadata().ID) + nextWaveformImgLock.Lock() + if cancel != nil { + cancel() + } + nextWaveformImgLock.Unlock() + id := item.Metadata().ID + path := p.cache.PathForCachedOrDownloadingFile(id) + go func() { + ctx, cncl := context.WithCancel(p.engine.ctx) + nextWaveformImgLock.Lock() + cancel = cncl + nextWaveformImgLock.Unlock() + wd, err := GetWaveformDataForFile(ctx, path, func() bool { + return p.cache.PathForCachedFile(id) != "" + }) + if err != nil { + log.Println(err.Error()) + } else { + im := NewWaveformImage() + GenerateWaveformImage(wd, im, color.White) + + if ctx.Err() != nil { + log.Println("canceled") + } + log.Println("have waveform image for track %s", item.Metadata().ID) + nextWaveformImgLock.Lock() + defer nextWaveformImgLock.Unlock() + nextWaveformImg = im + nextWaveformImgID = item.Metadata().ID + } + }() + } + }) + p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { // Autoplay if enabled and we are on the last track if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } - // TODO: make more permanent - if p.cache != nil && item != nil { - go func() { - if path := p.cache.PathForCachedFile(item.Metadata().ID); path != "" { - wd, err := GetWaveformDataForFile(context.Background(), path) - if err != nil { - log.Println(err.Error()) - } else { - im := NewWaveformImage() - GenerateWaveformImage(wd, im, color.White) - for _, cb := range p.onWaveformImgUpdate { - cb(im) - } - } - } else { - log.Println("no cached file for waveform image") - } - }() + if item != nil { + log.Println("Playing track ", item.Metadata().ID) + var im *WaveformImage + nextWaveformImgLock.Lock() + if nextWaveformImgID == item.Metadata().ID { + im = nextWaveformImg + } + nextWaveformImgLock.Unlock() + if im == nil { + im = zeroWaveformImage + } + for _, cb := range p.onWaveformImgUpdate { + cb(im) + } } if runtime.GOOS != "windows" { diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go new file mode 100644 index 0000000..545ed51 --- /dev/null +++ b/backend/util/filestreamer.go @@ -0,0 +1,110 @@ +package util + +import ( + "context" + "io" + "log" + "net" + "net/http" + "os" + "time" +) + +type FileStreamerServer struct { + Path string + IsComplete func() bool + listener net.Listener + server *http.Server + done chan struct{} +} + +// NewFileStreamerServer creates a new server but doesn't start it yet. +func NewFileStreamerServer(path string, isComplete func() bool) (*FileStreamerServer, error) { + listener, err := net.Listen("tcp", ":0") + if err != nil { + return nil, err + } + + fs := &FileStreamerServer{ + Path: path, + IsComplete: isComplete, + listener: listener, + done: make(chan struct{}), + } + + mux := http.NewServeMux() + mux.HandleFunc("/stream", fs.streamHandler) + + fs.server = &http.Server{ + Handler: mux, + } + + return fs, nil +} + +// Addr returns the server address (host:port). +func (fs *FileStreamerServer) Addr() string { + _, port, _ := net.SplitHostPort(fs.listener.Addr().String()) + return "http://localhost:" + port + "/stream" +} + +// Serve starts serving and waits for a single request to complete. +func (fs *FileStreamerServer) Serve() error { + go func() { + _ = fs.server.Serve(fs.listener) + }() + + <-fs.done // wait for the handler to finish + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + return fs.server.Shutdown(ctx) +} + +// Handler that streams the file using chunked transfer encoding. +func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Request) { + defer close(fs.done) // signal Serve() to shut down after this request + + file, err := os.Open(fs.Path) + if err != nil { + http.Error(w, "could not open file", http.StatusInternalServerError) + return + } + defer file.Close() + + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Transfer-Encoding", "chunked") + w.WriteHeader(http.StatusOK) + + flusher, canFlush := w.(http.Flusher) + + buf := make([]byte, 4096) + for { + n, err := file.Read(buf) + if err != nil && err != io.EOF { + log.Printf("read error: %v", err) + break + } + + if n > 0 { + _, err := w.Write(buf[:n]) + if err != nil { + log.Printf("client write error: %v", err) + break + } + if canFlush { + flusher.Flush() + } + } + + if n == 0 && fs.IsComplete() { + break + } + + // Wait for more content to be written to the source file + if n == 0 { + time.Sleep(50 * time.Millisecond) + } + } +} diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 201f4a3..26edad4 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -6,10 +6,12 @@ import ( "image" "image/color" "io" + "log" "math" "os" "path/filepath" + "github.com/dweymouth/supersonic/backend/util" "github.com/go-audio/audio" "github.com/go-audio/wav" "github.com/supersonic-app/go-mpv" @@ -71,10 +73,20 @@ func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Co } } -func GetWaveformDataForFile(ctx context.Context, fpath string) (*WaveformData, error) { +func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func() bool) (*WaveformData, error) { dir := filepath.Dir(fpath) transcodeFile := filepath.Join(dir, filepath.Base(fpath)+"_waveform.wav") + if !fileIsDone() { + srv, err := util.NewFileStreamerServer(fpath, fileIsDone) + if err != nil { + return nil, err + } + fpath = srv.Addr() + log.Println("streaming file to MPV at ", fpath) + go srv.Serve() + } + err := convertToWav(ctx, fpath, transcodeFile) if err != nil { return nil, err @@ -82,6 +94,7 @@ func GetWaveformDataForFile(ctx context.Context, fpath string) (*WaveformData, e f, err := os.Open(transcodeFile) if err != nil { + log.Println("error opening transcoded file") return nil, err } defer f.Close() @@ -199,7 +212,6 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { m.Command([]string{"loadfile", inPath, "replace"}) - //log.Println("generating wav file from %s using MPV", inPath) return mpvWaitForIdle(ctx, m) } From c521c617ec865d289424a1ea45811955bb6589a1 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 26 Jul 2025 15:33:22 -0700 Subject: [PATCH 08/23] incremental waveform generation (kind-of) working --- backend/app.go | 2 +- .../jellyfin/jellyfinmediaprovider.go | 14 +- backend/mediaprovider/model.go | 8 +- .../subsonic/subsonicmediaprovider.go | 6 +- backend/mpmedia_mac.go | 2 +- backend/playbackengine.go | 10 +- backend/playbackmanager.go | 104 +++--- backend/player/dlna/dlnaplayer.go | 14 +- backend/player/jukebox/jukeboxplayer.go | 2 +- backend/waveformimage.go | 345 ++++++++++++++---- ui/browsing/albumpage.go | 2 +- ui/browsing/nowplayingpage.go | 6 +- ui/browsing/playlistpage.go | 2 +- ui/dialogs/trackinfodialog.go | 2 +- ui/widgets/playqueuelist.go | 2 +- ui/widgets/tracklist.go | 2 +- ui/widgets/tracklistrow.go | 2 +- 17 files changed, 365 insertions(+), 160 deletions(-) diff --git a/backend/app.go b/backend/app.go index 5173a4f..e8143ab 100644 --- a/backend/app.go +++ b/backend/app.go @@ -409,7 +409,7 @@ func (a *App) SetupWindowsSMTC(hwnd uintptr) { } meta := nowPlaying.Metadata() smtc.UpdateMetadata(meta.Name, strings.Join(meta.Artists, ", ")) - smtc.UpdatePosition(0, meta.Duration*1000) + smtc.UpdatePosition(0, int(meta.Duration.Milliseconds())) go func() { a.ImageManager.GetCoverThumbnail(meta.CoverArtID) // ensure image is cached locally if path, err := a.ImageManager.GetCoverArtPath(meta.CoverArtID); err == nil { diff --git a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go index f84b951..48c091f 100644 --- a/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go +++ b/backend/mediaprovider/jellyfin/jellyfinmediaprovider.go @@ -15,8 +15,8 @@ import ( ) const ( - cacheValidDurationSeconds = 60 - runTimeTicksPerSecond = 10_000_000 + cacheValidDurationSeconds = 60 + runTimeTicksPerMicrosecond = 10 ) type JellyfinServer struct { @@ -383,7 +383,7 @@ func (j *jellyfinMediaProvider) TrackBeganPlayback(trackID string) error { } func (j *jellyfinMediaProvider) TrackEndedPlayback(trackID string, position int, submission bool) error { - return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerSecond) + return j.client.UpdatePlayStatus(trackID, jellyfin.Stop, int64(position)*runTimeTicksPerMicrosecond*1_000_000) } func (j *jellyfinMediaProvider) RescanLibrary() error { @@ -408,7 +408,7 @@ func (j *jellyfinMediaProvider) GetLyrics(tr *mediaprovider.Track) (*mediaprovid func toLyricLine(ll jellyfin.LyricLine) mediaprovider.LyricLine { return mediaprovider.LyricLine{ Text: ll.Text, - Start: float64(ll.Start) / float64(runTimeTicksPerSecond), + Start: (time.Duration(ll.Start/runTimeTicksPerMicrosecond) * time.Microsecond).Seconds(), } } @@ -432,7 +432,7 @@ func toTrack(ch *jellyfin.Song) *mediaprovider.Track { CoverArtID: coverArtID, ParentID: ch.AlbumID, Title: ch.Name, - Duration: int(ch.RunTimeTicks / runTimeTicksPerSecond), + Duration: time.Duration(ch.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond, TrackNumber: ch.IndexNumber, DiscNumber: ch.DiscNumber, //Genre: ch.Genres, @@ -483,7 +483,7 @@ func fillAlbum(a *jellyfin.Album, album *mediaprovider.Album) { album.ID = a.ID album.CoverArtID = a.ID album.Name = a.Name - album.Duration = int(a.RunTimeTicks / runTimeTicksPerSecond) + album.Duration = time.Duration(a.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond album.ArtistIDs = artistIDs album.ArtistNames = artistNames album.Date.Year = &a.Year @@ -505,7 +505,7 @@ func (j *jellyfinMediaProvider) fillPlaylist(p *jellyfin.Playlist, pl *mediaprov pl.CoverArtID = p.ID pl.Description = p.Overview pl.TrackCount = p.SongCount - pl.Duration = int(p.RunTimeTicks / runTimeTicksPerSecond) + pl.Duration = time.Duration(p.RunTimeTicks/runTimeTicksPerMicrosecond) * time.Microsecond // Jellyfin does not have public playlists pl.Owner = j.client.LoggedInUser() pl.Public = false diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index f2ebb27..626ea62 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -67,7 +67,7 @@ type Album struct { ID string CoverArtID string Name string - Duration int + Duration time.Duration ArtistIDs []string ArtistNames []string Date ItemDate @@ -127,7 +127,7 @@ type Track struct { CoverArtID string ParentID string Title string - Duration int + Duration time.Duration TrackNumber int DiscNumber int Genres []string @@ -165,7 +165,7 @@ type Playlist struct { Description string Public bool Owner string - Duration int + Duration time.Duration TrackCount int } @@ -216,7 +216,7 @@ type MediaItemMetadata struct { Album string AlbumID string CoverArtID string - Duration int + Duration time.Duration } type MediaItem interface { diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 3f38069..c3bf658 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -551,7 +551,7 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { CoverArtID: ch.CoverArt, ParentID: ch.Parent, Title: ch.Title, - Duration: ch.Duration, + Duration: time.Duration(ch.Duration) * time.Second, TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, Genres: genres, @@ -628,7 +628,7 @@ func fillAlbum(subAlbum *subsonic.AlbumID3, album *mediaprovider.Album) { album.ID = subAlbum.ID album.CoverArtID = subAlbum.CoverArt album.Name = subAlbum.Name - album.Duration = subAlbum.Duration + album.Duration = time.Duration(subAlbum.Duration) * time.Second album.ArtistIDs = artistIDs album.ArtistNames = artistNames album.TrackCount = subAlbum.SongCount @@ -714,7 +714,7 @@ func fillPlaylist(pl *subsonic.Playlist, playlist *mediaprovider.Playlist) { playlist.Owner = pl.Owner playlist.Public = pl.Public playlist.TrackCount = pl.SongCount - playlist.Duration = pl.Duration + playlist.Duration = time.Duration(pl.Duration) * time.Second } func (s *subsonicMediaProvider) GetSongRadio(trackID string, count int) ([]*mediaprovider.Track, error) { diff --git a/backend/mpmedia_mac.go b/backend/mpmedia_mac.go index e7616ee..84fdd6f 100644 --- a/backend/mpmedia_mac.go +++ b/backend/mpmedia_mac.go @@ -112,7 +112,7 @@ func (mp *MPMediaHandler) updateMetadata(meta *mediaprovider.MediaItemMetadata) } } artist = strings.Join(meta.Artists, ", ") - duration = meta.Duration + duration = int(meta.Duration.Seconds()) } cTitle := C.CString(title) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index d1123ed..943c36c 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -564,9 +564,9 @@ func (p *playbackEngine) SetReplayGainMode(mode player.ReplayGainMode) { func (p *playbackEngine) cacheNextTracks() { if p.audiocache != nil { // fetch up to the 2 next tracks in the queue to the cache - fetch := make([]AudioCacheRequest, 0, 2) - for _, idx := range [2]int{p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} { - if idx < len(p.playQueue) { + fetch := make([]AudioCacheRequest, 0, 3) + for _, idx := range [3]int{p.nowPlayingIdx, p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} { + if idx > 0 && idx < len(p.playQueue) { item := p.playQueue[idx] if item.Metadata().Type == mediaprovider.MediaItemTypeTrack { fetch = append(fetch, AudioCacheRequest{ @@ -610,7 +610,7 @@ func (p *playbackEngine) handleOnTrackChange() { p.wasStopped = false p.alreadyScrobbled = false - p.curTrackDuration = float64(nowPlaying.Metadata().Duration) + p.curTrackDuration = nowPlaying.Metadata().Duration.Seconds() p.sendNowPlayingScrobble() // Must come before invokeOnChangeCallbacks b/c track may immediately be scrobbled p.invokeOnSongChangeCallbacks() p.handleTimePosUpdate(false) @@ -841,7 +841,7 @@ func (p *playbackEngine) handleTimePosUpdate(seeked bool) { if np := p.NowPlaying(); np != nil { meta = np.Metadata() } - isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > float64(meta.Duration)-10 + isNearEnd := meta.Type != mediaprovider.MediaItemTypeRadioStation && s.TimePos > meta.Duration.Seconds()-10 if p.needToSetNextTrack && isNearEnd { p.needToSetNextTrack = false p.setNextTrack(p.nextPlayingIndex()) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 3b7c2fe..d18f520 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -3,7 +3,6 @@ package backend import ( "context" "fmt" - "image/color" "log" "math/rand" "runtime" @@ -24,6 +23,7 @@ import ( // intermediary between the frontend and various Player backends. type PlaybackManager struct { engine *playbackEngine + wfmGen *WaveformImageGenerator cache *AudioCache cmdQueue *playbackCommandQueue cfg *AppConfig @@ -47,13 +47,6 @@ type RemotePlaybackDevice struct { new func() (player.BasePlayer, error) } -var zeroWaveformImage *WaveformImage - -func init() { - zeroWaveformImage = NewWaveformImage() - GenerateWaveformImage(&WaveformData{}, zeroWaveformImage, color.White) -} - func NewPlaybackManager( ctx context.Context, s *ServerManager, @@ -74,6 +67,9 @@ func NewPlaybackManager( localPlayer: p, cache: c, } + if c != nil { + pm.wfmGen = NewWaveformImageGenerator(c) + } pm.addOnTrackChangeHook() go pm.runCmdQueue(ctx) return pm @@ -88,45 +84,19 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) - var nextWaveformImgLock sync.Mutex - var nextWaveformImg *WaveformImage - var nextWaveformImgID string - var cancel context.CancelFunc + var curWaveformJob *WaveformImageJob + var nextWaveformJob *WaveformImageJob + var refreshCancel context.CancelFunc p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { - if p.cache != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { log.Println("preparing waveform image for next track ", item.Metadata().ID) - nextWaveformImgLock.Lock() - if cancel != nil { - cancel() - } - nextWaveformImgLock.Unlock() id := item.Metadata().ID - path := p.cache.PathForCachedOrDownloadingFile(id) - go func() { - ctx, cncl := context.WithCancel(p.engine.ctx) - nextWaveformImgLock.Lock() - cancel = cncl - nextWaveformImgLock.Unlock() - wd, err := GetWaveformDataForFile(ctx, path, func() bool { - return p.cache.PathForCachedFile(id) != "" - }) - if err != nil { - log.Println(err.Error()) - } else { - im := NewWaveformImage() - GenerateWaveformImage(wd, im, color.White) + _ = p.cache.PathForCachedOrDownloadingFile(id) - if ctx.Err() != nil { - log.Println("canceled") - } - log.Println("have waveform image for track %s", item.Metadata().ID) - nextWaveformImgLock.Lock() - defer nextWaveformImgLock.Unlock() - nextWaveformImg = im - nextWaveformImgID = item.Metadata().ID - } - }() + curWaveformJob.Cancel() + curWaveformJob = nextWaveformJob + nextWaveformJob = p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track)) } }) @@ -135,20 +105,52 @@ func (p *PlaybackManager) addOnTrackChangeHook() { if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } + if refreshCancel != nil { + refreshCancel() + } + + updateUnfinishedJob := func(job *WaveformImageJob) { + ctx, c := context.WithCancel(p.cache.rootCtx) + refreshCancel = c + log.Println("starting img update func") + go func(ctx context.Context, job *WaveformImageJob) { + for { + time.Sleep(333 * time.Millisecond) + select { + case <-ctx.Done(): + return + default: + log.Println("updating waveform img") + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if job.Done() { + return + } + } + } + }(ctx, job) + } if item != nil { log.Println("Playing track ", item.Metadata().ID) var im *WaveformImage - nextWaveformImgLock.Lock() - if nextWaveformImgID == item.Metadata().ID { - im = nextWaveformImg + done := false + if nextWaveformJob.ItemID == item.Metadata().ID { + done = nextWaveformJob.Done() + im = nextWaveformJob.Get() } - nextWaveformImgLock.Unlock() - if im == nil { - im = zeroWaveformImage - } - for _, cb := range p.onWaveformImgUpdate { - cb(im) + if im != nil { + for _, cb := range p.onWaveformImgUpdate { + cb(im) + } + if !done { + updateUnfinishedJob(nextWaveformJob) + } + } else if tr, ok := item.(*mediaprovider.Track); ok { + curWaveformJob = p.wfmGen.StartWaveformGeneration(tr) + updateUnfinishedJob(curWaveformJob) } } diff --git a/backend/player/dlna/dlnaplayer.go b/backend/player/dlna/dlnaplayer.go index 60aaf23..bdaf4a8 100644 --- a/backend/player/dlna/dlnaplayer.go +++ b/backend/player/dlna/dlnaplayer.go @@ -181,8 +181,8 @@ func (d *DLNAPlayer) PlayFile(urlstr string, meta mediaprovider.MediaItemMetadat }() } d.state = playing - remainingDur := meta.Duration - int(startTime) - d.setTrackChangeTimer(time.Duration(remainingDur) * time.Second) + remainingDur := meta.Duration - time.Duration(startTime)*time.Second + d.setTrackChangeTimer(remainingDur) d.stopwatch.Reset() d.stopwatch.Start() d.lastStartTime = int(startTime) @@ -265,7 +265,7 @@ func (d *DLNAPlayer) Continue() error { return err } d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - d.curPlayPos() + nextTrackChange := d.curTrackMeta.Duration - d.curPlayPos() d.metaLock.Unlock() d.state = playing d.setTrackChangeTimer(nextTrackChange) @@ -349,7 +349,7 @@ func (d *DLNAPlayer) SeekSeconds(secs float64) error { if d.state == playing { d.metaLock.Lock() - nextTrackChange := time.Duration(d.curTrackMeta.Duration)*time.Second - time.Duration(secs)*time.Second + nextTrackChange := d.curTrackMeta.Duration - time.Duration(secs)*time.Second d.metaLock.Unlock() d.setTrackChangeTimer(nextTrackChange) d.stopwatch.Start() @@ -397,7 +397,7 @@ func (d *DLNAPlayer) GetStatus() player.Status { return player.Status{ State: state, TimePos: timePos, - Duration: float64(d.curTrackMeta.Duration), + Duration: d.curTrackMeta.Duration.Seconds(), } } @@ -428,7 +428,7 @@ func (d *DLNAPlayer) syncPlaybackTime() { if d.state == playing { d.stopwatch.Start() } - d.setTrackChangeTimer(time.Duration(d.curTrackMeta.Duration-d.lastStartTime) * time.Second) + d.setTrackChangeTimer(d.curTrackMeta.Duration - time.Duration(d.lastStartTime)*time.Second) d.InvokeOnSeek() } } @@ -511,7 +511,7 @@ func (d *DLNAPlayer) handleOnTrackChange() { } d.curTrackMeta = d.nextTrackMeta d.nextTrackMeta = mediaprovider.MediaItemMetadata{} - nextTrackChange := time.Duration(d.curTrackMeta.Duration) * time.Second + nextTrackChange := d.curTrackMeta.Duration d.metaLock.Unlock() if stopping { diff --git a/backend/player/jukebox/jukeboxplayer.go b/backend/player/jukebox/jukeboxplayer.go index b4bad7d..fd7be0a 100644 --- a/backend/player/jukebox/jukeboxplayer.go +++ b/backend/player/jukebox/jukeboxplayer.go @@ -90,7 +90,7 @@ func (j *JukeboxPlayer) PlayTrack(track *mediaprovider.Track, _ float64) error { j.curTrack = 0 j.queueLength = 1 - j.curTrackDuration = float64(track.Duration) + j.curTrackDuration = track.Duration.Seconds() return nil } diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 26edad4..a2a8f2c 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -3,6 +3,7 @@ package backend import ( "context" "errors" + "fmt" "image" "image/color" "io" @@ -10,16 +11,18 @@ import ( "math" "os" "path/filepath" + "sync" + "time" + "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/backend/util" "github.com/go-audio/audio" "github.com/go-audio/wav" "github.com/supersonic-app/go-mpv" ) -type WaveformData struct { - Peak [1024]byte - RMS [1024]byte +type WaveformImageGenerator struct { + audioCache *AudioCache } type WaveformImage = image.NRGBA @@ -28,27 +31,165 @@ func NewWaveformImage() *WaveformImage { return image.NewNRGBA(image.Rect(0, 0, 1024, 32)) } -func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Color) { - centerY := imgbuf.Rect.Dy() / 2 // 16 +type WaveformImageJob struct { + ItemID string + lock sync.Mutex + img *WaveformImage + err error + progress int // first invalid pixel in X direction + cancel func() +} + +func (w *WaveformImageJob) Cancel() { + if w != nil && w.cancel != nil { + w.cancel() + } +} + +func (w *WaveformImageJob) Done() bool { + w.lock.Lock() + defer w.lock.Unlock() + return w.err != nil || w.progress >= w.img.Bounds().Dx() +} + +func (w *WaveformImageJob) Err() error { + w.lock.Lock() + defer w.lock.Unlock() + return w.err +} + +func (w *WaveformImageJob) Get() *WaveformImage { + if w.Done() { + log.Println("returning image directly") + return w.img + } + // return a new *WaveformImage with data copied + // from the valid region of w.img + height := w.img.Bounds().Dy() + result := NewWaveformImage() + + // Copy each scanline from w.img to result + for y := 0; y < height; y++ { + srcOffset := w.img.PixOffset(0, y) + dstOffset := result.PixOffset(0, y) + copy(result.Pix[dstOffset:dstOffset+w.progress*4], w.img.Pix[srcOffset:srcOffset+w.progress*4]) + } + + return result +} + +func NewWaveformImageGenerator(cache *AudioCache) *WaveformImageGenerator { + return &WaveformImageGenerator{audioCache: cache} +} + +func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Track) *WaveformImageJob { + ctx, cancel := context.WithCancel(w.audioCache.rootCtx) + job := &WaveformImageJob{ + img: NewWaveformImage(), + ItemID: item.ID, + cancel: cancel, + } + + // Set up a pipeline of concurrent tasks that need to complete to generate + // a waveform image: + // 1. Begin downloading the file from the server + // 2. Begin transcoding it to WAV + // 3. Begin analyzing the resulting WAV file + // 4. Begin generating the image from the analysis data + go func() { + path := w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + // wait for file to begin downloading if not already + for path == "" { + time.Sleep(10 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + } + + dir := filepath.Dir(path) + transcodeFile := filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + + fileDone := func() bool { + return w.audioCache.PathForCachedFile(job.ItemID) != "" + } + + // If file isn't fully downloaded from server, + // stream it to MPV via HTTP so it doesn't possibly + // terminate the conversion to WAV early encountering EOF + if !fileDone() { + srv, err := util.NewFileStreamerServer(path, fileDone) + if err != nil { + job.setError(err) + return + } + path = srv.Addr() + log.Println("streaming file to MPV at ", path) + go srv.Serve() + } + + // Start converting the file to WAV for analysis + var wavConvertDone bool + go func() { + err := convertToWav(ctx, path, transcodeFile) + wavConvertDone = true + if err != nil { + job.setError(err) + } + }() + + // Wait for transcoded WAV file to begin being written + for { + if s, err := os.Stat(transcodeFile); err == nil && s.Size() > 0 { + break + } + time.Sleep(10 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + } + + // Start analyzing the converted wav file + data := &waveformData{} + go func() { + err := analyzeWavFile(ctx, transcodeFile, data, item.Duration.Milliseconds(), func() bool { return wavConvertDone }) + if err != nil { + log.Println("error analyzing wav", err.Error()) + job.setError(err) + } + }() + + // Start generating the waveform image + go generateWaveformImage(ctx, data, job) + }() + return job +} + +type waveformData struct { + Peak [1024]byte + RMS [1024]byte + + progress int // first invalid index for Peak/RMS data +} + +func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) { + centerY := job.img.Rect.Dy() / 2 // 16 top := centerY - 1 bottom := centerY - // Convert the input color to RGBA - r, g, b, _ := c.RGBA() - opaqueColor := color.NRGBA{ - R: uint8(r >> 8), - G: uint8(g >> 8), - B: uint8(b >> 8), - A: 255, - } - translucentColor := color.NRGBA{ - R: uint8(r >> 8), - G: uint8(g >> 8), - B: uint8(b >> 8), - A: 128, // 50% opacity - } + opaqueColor := color.NRGBA{R: 255, G: 255, B: 255, A: 255} + translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128} for x := 0; x < 1024; x++ { + if data.progress <= x { + time.Sleep(10 * time.Millisecond) + if ctx.Err() != nil { + return // expired + } + } + rms := float64(data.RMS[x]) / 255.0 peak := float64(data.Peak[x]) / 255.0 @@ -56,80 +197,110 @@ func GenerateWaveformImage(data *WaveformData, imgbuf *WaveformImage, c color.Co peakPixels := int((peak - rms) * 16) // Always draw at least 2 center pixels - setPixel(imgbuf, x, top, opaqueColor) - setPixel(imgbuf, x, bottom, opaqueColor) + setPixel(job.img, x, top, opaqueColor) + setPixel(job.img, x, bottom, opaqueColor) // Draw RMS pixels (solid) for i := 1; i <= rmsPixels; i++ { - setPixel(imgbuf, x, top-i, opaqueColor) - setPixel(imgbuf, x, bottom+i, opaqueColor) + setPixel(job.img, x, top-i, opaqueColor) + setPixel(job.img, x, bottom+i, opaqueColor) } // Draw Peak extension (translucent) for i := 1; i <= peakPixels; i++ { - setPixel(imgbuf, x, top-rmsPixels-i, translucentColor) - setPixel(imgbuf, x, bottom+rmsPixels+i, translucentColor) + setPixel(job.img, x, top-rmsPixels-i, translucentColor) + setPixel(job.img, x, bottom+rmsPixels+i, translucentColor) } + job.progress = x + 1 } + log.Println("done generating image") } -func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func() bool) (*WaveformData, error) { - dir := filepath.Dir(fpath) - transcodeFile := filepath.Join(dir, filepath.Base(fpath)+"_waveform.wav") - - if !fileIsDone() { - srv, err := util.NewFileStreamerServer(fpath, fileIsDone) - if err != nil { - return nil, err - } - fpath = srv.Addr() - log.Println("streaming file to MPV at ", fpath) - go srv.Serve() +func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { + if fileDone() { + log.Println("Analyzing completely written file!!") } - - err := convertToWav(ctx, fpath, transcodeFile) - if err != nil { - return nil, err - } - f, err := os.Open(transcodeFile) if err != nil { log.Println("error opening transcoded file") - return nil, err + return err } defer f.Close() - defer os.Remove(transcodeFile) + //defer os.Remove(transcodeFile) - decoder := wav.NewDecoder(f) + reader := trackingReader{rs: f} + + decoder := wav.NewDecoder(&reader) if !decoder.IsValidFile() { - return nil, errors.New("invalid wav file") - } - - dur, err := decoder.Duration() - if err != nil { - return nil, err + return errors.New("invalid wav file") } format := decoder.Format() - totalSamples := format.SampleRate * int(dur.Milliseconds()) / 1000 + totalSamples := format.SampleRate * int(millisecs) / 1000 samplesPerChunk := totalSamples / 1024 if err := decoder.FwdToPCM(); err != nil { - return nil, err + return err } buf := &audio.IntBuffer{Data: make([]int, 4096)} - data := &WaveformData{} curChunk := 0 chunkSamples := make([]float32, 0, samplesPerChunk) + + // file read loop for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if !fileDone() { + // Check how many samples we can safely read without encountering EOF + // and adjust read buffer size accordingly + + stat, err := f.Stat() + if err != nil { + return fmt.Errorf("stat failed: %w", err) + } + currentSize := stat.Size() + + readableBytes := currentSize - reader.Pos() // how many bytes are still available + + // Estimate how many samples we can read + bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel + maxSamples := int(readableBytes / bytesPerSample) + + if maxSamples <= 0 { + // Wait for more data to be written to file + time.Sleep(10 * time.Millisecond) + continue + } + + // Resize buffer to fit only what’s safe + safeSamples := maxSamples + if safeSamples > cap(buf.Data) { + safeSamples = cap(buf.Data) + } + buf.Data = buf.Data[:safeSamples] + } else { + // File is done being written, resize read buf to the max + buf.Data = buf.Data[:cap(buf.Data)] + } + n, err := decoder.PCMBuffer(buf) if n == 0 || err == io.EOF { + if fileDone() { + data.progress = 1024 // set progress to done + } + if err == io.EOF && !fileDone() { + return errors.New("WAV read got premature EOF") + } break } if err != nil { - return data, err + return err } // Process samples @@ -150,6 +321,7 @@ func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func() data.RMS[curChunk] = float32ToByte(rms) } curChunk++ + data.progress = curChunk chunkSamples = chunkSamples[:0] if curChunk >= 1024 { break @@ -158,14 +330,24 @@ func GetWaveformDataForFile(ctx context.Context, fpath string, fileIsDone func() } } - // Optionally fill the last chunk if it's partially filled + // analyze the last chunk if it's partially filled with samples if curChunk < 1024 && len(chunkSamples) > 0 { peak, rms := computePeakAndRMS(chunkSamples) data.Peak[curChunk] = float32ToByte(peak) data.RMS[curChunk] = float32ToByte(rms) + data.progress = curChunk + 1 } - return data, nil + log.Println("final chunk is", curChunk) + + return nil +} + +func (j *WaveformImageJob) setError(err error) { + j.lock.Lock() + defer j.lock.Unlock() + + j.err = err } func computePeakAndRMS(chunk []float32) (peak float32, rms float32) { @@ -212,18 +394,7 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { m.Command([]string{"loadfile", inPath, "replace"}) - return mpvWaitForIdle(ctx, m) -} - -func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { - offset := img.PixOffset(x, y) - img.Pix[offset+0] = c.R - img.Pix[offset+1] = c.G - img.Pix[offset+2] = c.B - img.Pix[offset+3] = c.A -} - -func mpvWaitForIdle(ctx context.Context, m *mpv.Mpv) error { + // Wait for MPV idle or ctx expiry for { select { case <-ctx.Done(): @@ -235,10 +406,42 @@ func mpvWaitForIdle(ctx context.Context, m *mpv.Mpv) error { } // use small timeout to allow detecting ctx expiry // without too much delay - e := m.WaitEvent(0.1 /*timeout seconds*/) + e := m.WaitEvent(0.05 /*timeout seconds*/) if e.Event_Id == mpv.EVENT_IDLE { return nil } } } } + +func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { + offset := img.PixOffset(x, y) + img.Pix[offset+0] = c.R + img.Pix[offset+1] = c.G + img.Pix[offset+2] = c.B + img.Pix[offset+3] = c.A +} + +// wrap an io.ReadSeeker with support for tracking bytes read (Pos()) +type trackingReader struct { + rs io.ReadSeeker + pos int64 +} + +func (t *trackingReader) Read(p []byte) (int, error) { + n, err := t.rs.Read(p) + t.pos += int64(n) + return n, err +} + +func (t *trackingReader) Seek(offset int64, whence int) (int64, error) { + newPos, err := t.rs.Seek(offset, whence) + if err == nil { + t.pos = newPos + } + return newPos, err +} + +func (t *trackingReader) Pos() int64 { + return t.pos +} diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 0223958..8bb04a4 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -404,7 +404,7 @@ func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string { if y := a.ReissueDate.Year; y != nil && *y > a.YearOrZero() { yearStr += fmt.Sprintf(" (%s %s)", lang.L("reissued"), util.FormatItemDate(a.ReissueDate)) } - return fmt.Sprintf("%s · %s · %s%s", yearStr, tracksMsg, discs, util.SecondsToTimeString(float64(a.Duration))) + return fmt.Sprintf("%s · %s · %s%s", yearStr, tracksMsg, discs, util.SecondsToTimeString(a.Duration.Seconds())) } func (s *albumPageState) Restore() Page { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 7bd3563..90ff4f3 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -375,7 +375,7 @@ func (a *NowPlayingPage) fetchLyrics(ctx context.Context, song *mediaprovider.Tr } } if lyrics == nil && a.lrcFetch != nil { - lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, song.Duration) + lyrics, err = a.lrcFetch.FetchLrcLibLyrics(song.Title, song.ArtistNames[0], song.Album, int(song.Duration.Seconds())) if err != nil { log.Println(err.Error()) } @@ -445,7 +445,7 @@ func (a *NowPlayingPage) Reload() { a.queueList.SetItems(a.queue) a.totalTime = 0.0 for _, tr := range a.queue { - a.totalTime += float64(tr.Metadata().Duration) + a.totalTime += tr.Metadata().Duration.Seconds() } a.formatStatusLine() @@ -546,7 +546,7 @@ func (a *NowPlayingPage) formatStatusLine() { dur := 0.0 if np := a.pm.NowPlaying(); np != nil { - dur = float64(np.Metadata().Duration) + dur = np.Metadata().Duration.Seconds() } statusSuffix := "" trackNum := 0 diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index a9b3cf1..5448eb1 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -514,7 +514,7 @@ func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *mediaprovider.Playlis fallbackTracksMsg := fmt.Sprintf("%d %s", p.TrackCount, tracks) tracksMsg := lang.LocalizePluralKey("{{.trackCount}} tracks", fallbackTracksMsg, p.TrackCount, map[string]string{"trackCount": strconv.Itoa(p.TrackCount)}) - return fmt.Sprintf("%s, %s", tracksMsg, util.SecondsToTimeString(float64(p.Duration))) + return fmt.Sprintf("%s, %s", tracksMsg, util.SecondsToTimeString(p.Duration.Seconds())) } func (s *playlistPageState) Restore() Page { diff --git a/ui/dialogs/trackinfodialog.go b/ui/dialogs/trackinfodialog.go index 050d22c..f4d7205 100644 --- a/ui/dialogs/trackinfodialog.go +++ b/ui/dialogs/trackinfodialog.go @@ -82,7 +82,7 @@ func (t *TrackInfoDialog) CreateRenderer() fyne.WidgetRenderer { c.Add(genres) } - addFormRow(c, lang.L("Duration"), util.SecondsToTimeString(float64(t.track.Duration))) + addFormRow(c, lang.L("Duration"), util.SecondsToTimeString(t.track.Duration.Seconds())) addFormRow(c, lang.L("Comment"), t.track.Comment) addFormRow(c, lang.L("Year"), strconv.Itoa(t.track.Year)) addFormRow(c, lang.L("Track number"), strconv.Itoa(t.track.TrackNumber)) diff --git a/ui/widgets/playqueuelist.go b/ui/widgets/playqueuelist.go index f8532b2..ba536ed 100644 --- a/ui/widgets/playqueuelist.go +++ b/ui/widgets/playqueuelist.go @@ -453,7 +453,7 @@ func (p *PlayQueueListRow) Update(tm *util.TrackListModel, rowNum int) { p.title.Text = meta.Name p.title.SetToolTip(meta.Name) p.artist.BuildSegments(meta.Artists, meta.ArtistIDs) - p.time.Text = util.SecondsToMMSS(float64(meta.Duration)) + p.time.Text = util.SecondsToMMSS(meta.Duration.Seconds()) } // Render whether track is playing or not diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 8a05fb3..05c90b6 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -439,7 +439,7 @@ func (t *Tracklist) doSortTracks() { case ColumnRating: t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Rating) }) case ColumnTime: - t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Duration) }) + t.intSort(func(tr *util.TrackListModel) int64 { return tr.Track().Duration.Milliseconds() }) case ColumnYear: t.intSort(func(tr *util.TrackListModel) int64 { return int64(tr.Track().Year) }) case ColumnSize: diff --git a/ui/widgets/tracklistrow.go b/ui/widgets/tracklistrow.go index 5d4f33a..411418e 100644 --- a/ui/widgets/tracklistrow.go +++ b/ui/widgets/tracklistrow.go @@ -401,7 +401,7 @@ func (t *tracklistRowBase) doUpdate(tm *util.TrackListModel, rowNum int) { t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) t.album.BuildSegments([]string{tr.Album}, []string{tr.AlbumID}) t.composer.BuildSegments(tr.ComposerNames, tr.ComposerIDs) - t.dur.Text = util.SecondsToMMSS(float64(tr.Duration)) + t.dur.Text = util.SecondsToMMSS(tr.Duration.Seconds()) t.year.Text = strconv.Itoa(tr.Year) t.plays.Text = strconv.Itoa(int(tr.PlayCount)) t.comment.Text = strings.ReplaceAll(tr.Comment, "\n", " ") From b9ef1e86a475afedeae4786ce059a82dc56eeb63 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 26 Jul 2025 18:58:38 -0700 Subject: [PATCH 09/23] fix bug leading to gaps in waveform image --- backend/waveformimage.go | 57 +++++++++------------------------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/backend/waveformimage.go b/backend/waveformimage.go index a2a8f2c..470d381 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -183,11 +183,12 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128} for x := 0; x < 1024; x++ { - if data.progress <= x { + for data.progress <= x { time.Sleep(10 * time.Millisecond) if ctx.Err() != nil { return // expired } + continue } rms := float64(data.RMS[x]) / 255.0 @@ -216,6 +217,7 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor log.Println("done generating image") } +// assumes mono, 16 bit func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { if fileDone() { log.Println("Analyzing completely written file!!") @@ -226,11 +228,9 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat return err } defer f.Close() - //defer os.Remove(transcodeFile) + defer os.Remove(transcodeFile) - reader := trackingReader{rs: f} - - decoder := wav.NewDecoder(&reader) + decoder := wav.NewDecoder(f) if !decoder.IsValidFile() { return errors.New("invalid wav file") } @@ -247,6 +247,7 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat buf := &audio.IntBuffer{Data: make([]int, 4096)} curChunk := 0 chunkSamples := make([]float32, 0, samplesPerChunk) + bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel // file read loop for { @@ -266,10 +267,10 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat } currentSize := stat.Size() - readableBytes := currentSize - reader.Pos() // how many bytes are still available + // how many bytes can we read without nearing EOF + readableBytes := currentSize - int64(samplesPerChunk)*int64(curChunk)*bytesPerSample - 16384 //buffer for safety // Estimate how many samples we can read - bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel maxSamples := int(readableBytes / bytesPerSample) if maxSamples <= 0 { @@ -279,10 +280,7 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat } // Resize buffer to fit only what’s safe - safeSamples := maxSamples - if safeSamples > cap(buf.Data) { - safeSamples = cap(buf.Data) - } + safeSamples := min(maxSamples, cap(buf.Data)) buf.Data = buf.Data[:safeSamples] } else { // File is done being written, resize read buf to the max @@ -293,25 +291,20 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat if n == 0 || err == io.EOF { if fileDone() { data.progress = 1024 // set progress to done + break } if err == io.EOF && !fileDone() { return errors.New("WAV read got premature EOF") } - break + continue } if err != nil { return err } // Process samples - for i := 0; i < n; i += format.NumChannels { - sum := 0 - for c := 0; c < format.NumChannels; c++ { - sum += buf.Data[i+c] - } - avg := float64(sum) / float64(format.NumChannels) - // TODO: this assumes 16 bit - sample := float32(avg / float64(1<<15)) // Normalize to [-1, 1] + for i := 0; i < n; i++ { + sample := float32(buf.Data[i]) / float32(1<<15) // Normalize to [-1, 1] chunkSamples = append(chunkSamples, sample) if len(chunkSamples) >= samplesPerChunk { @@ -421,27 +414,3 @@ func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) { img.Pix[offset+2] = c.B img.Pix[offset+3] = c.A } - -// wrap an io.ReadSeeker with support for tracking bytes read (Pos()) -type trackingReader struct { - rs io.ReadSeeker - pos int64 -} - -func (t *trackingReader) Read(p []byte) (int, error) { - n, err := t.rs.Read(p) - t.pos += int64(n) - return n, err -} - -func (t *trackingReader) Seek(offset int64, whence int) (int64, error) { - newPos, err := t.rs.Seek(offset, whence) - if err == nil { - t.pos = newPos - } - return newPos, err -} - -func (t *trackingReader) Pos() int64 { - return t.pos -} From 777ec95cfd34f3f1338d57e1e3c5da50919c144a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 26 Jul 2025 22:25:01 -0700 Subject: [PATCH 10/23] fix bugs in done signalling --- backend/waveformimage.go | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 470d381..e4dd45b 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -37,6 +37,7 @@ type WaveformImageJob struct { img *WaveformImage err error progress int // first invalid pixel in X direction + done bool cancel func() } @@ -49,7 +50,7 @@ func (w *WaveformImageJob) Cancel() { func (w *WaveformImageJob) Done() bool { w.lock.Lock() defer w.lock.Unlock() - return w.err != nil || w.progress >= w.img.Bounds().Dx() + return w.err != nil || w.done } func (w *WaveformImageJob) Err() error { @@ -172,9 +173,12 @@ type waveformData struct { RMS [1024]byte progress int // first invalid index for Peak/RMS data + done bool } func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) { + defer func() { job.done = true }() + centerY := job.img.Rect.Dy() / 2 // 16 top := centerY - 1 bottom := centerY @@ -184,11 +188,13 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor for x := 0; x < 1024; x++ { for data.progress <= x { - time.Sleep(10 * time.Millisecond) + if data.done { + return + } if ctx.Err() != nil { return // expired } - continue + time.Sleep(10 * time.Millisecond) } rms := float64(data.RMS[x]) / 255.0 @@ -214,14 +220,12 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor } job.progress = x + 1 } - log.Println("done generating image") } // assumes mono, 16 bit func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { - if fileDone() { - log.Println("Analyzing completely written file!!") - } + defer func() { data.done = true }() + f, err := os.Open(transcodeFile) if err != nil { log.Println("error opening transcoded file") @@ -290,7 +294,6 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat n, err := decoder.PCMBuffer(buf) if n == 0 || err == io.EOF { if fileDone() { - data.progress = 1024 // set progress to done break } if err == io.EOF && !fileDone() { @@ -331,8 +334,6 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat data.progress = curChunk + 1 } - log.Println("final chunk is", curChunk) - return nil } From 9844bbf6cbc4195ee305c1504ce26ddbf33b6fe5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 27 Jul 2025 17:07:11 -0700 Subject: [PATCH 11/23] waveforms of first played track now generating, but still bugs with ending the task --- backend/audiocache.go | 23 +++-- backend/playbackengine.go | 23 +++-- backend/playbackmanager.go | 4 +- backend/util/filestreamer.go | 29 +++++-- backend/waveformimage.go | 153 +++++++++++++++++++++++++++------- ui/widgets/waveformseekbar.go | 2 +- 6 files changed, 180 insertions(+), 54 deletions(-) diff --git a/backend/audiocache.go b/backend/audiocache.go index ee9141d..fb5ba4a 100644 --- a/backend/audiocache.go +++ b/backend/audiocache.go @@ -53,6 +53,9 @@ func NewAudioCache(ctx context.Context, s *ServerManager, baseCacheDir string) ( // PathForCachedFile returns the local filesystem path for a cached track, // if the file has finished downloading. If not cached, it returns an empty string. func (a *AudioCache) PathForCachedFile(id string) string { + a.mutex.Lock() + defer a.mutex.Unlock() + if item, ok := a.entries[id]; ok && item.done { return a.pathForID(id) } @@ -63,20 +66,29 @@ func (a *AudioCache) PathForCachedFile(id string) string { // including one that is in the process of downloading. // If it is not cached or downloading, it returns an empty string. func (a *AudioCache) PathForCachedOrDownloadingFile(id string) string { + a.mutex.Lock() + defer a.mutex.Unlock() + if _, ok := a.entries[id]; ok { return a.pathForID(id) } return "" } -// cacheFile begins downloading a file (if not already downloading) and stores it +// CacheFile begins downloading a file (if not already downloading) and stores it // to the cache directory under its ID as filename. The download is asynchronous. -func (a *AudioCache) cacheFile(id, dlURL string) { +func (a *AudioCache) CacheFile(id, dlURL string) { s := a.s.Server if s == nil { return } + a.mutex.Lock() + defer a.mutex.Unlock() + a.cacheFile(id, dlURL) +} + +func (a *AudioCache) cacheFile(id, dlURL string) { if _, ok := a.entries[id]; !ok { ctx, cancel := context.WithCancel(a.rootCtx) a.entries[id] = &cacheEntry{cancel: cancel} @@ -107,9 +119,10 @@ func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { return a.ID == id }) { - e.cancel() - _ = os.Remove(a.pathForID(id)) - delete(a.entries, id) + _ = e + //e.cancel() + //_ = os.Remove(a.pathForID(id)) + //delete(a.entries, id) } } diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 943c36c..1d5b109 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -565,7 +565,10 @@ func (p *playbackEngine) cacheNextTracks() { if p.audiocache != nil { // fetch up to the 2 next tracks in the queue to the cache fetch := make([]AudioCacheRequest, 0, 3) - for _, idx := range [3]int{p.nowPlayingIdx, p.nowPlayingIdx + 1, p.nowPlayingIdx + 2} { + // if nothing is playing (index = -1), treat the beginning of the queue as + // the "currently" playing track, since we're probably about to play it + npI := max(p.nowPlayingIdx, 0) + for _, idx := range [3]int{npI, npI + 1, npI + 2} { if idx > 0 && idx < len(p.playQueue) { item := p.playQueue[idx] if item.Metadata().Type == mediaprovider.MediaItemTypeTrack { @@ -581,6 +584,7 @@ func (p *playbackEngine) cacheNextTracks() { id = np.Metadata().ID } p.audiocache.CacheOnly(id, fetch) + log.Println("fetching files", sharedutil.MapSlice(fetch, func(a AudioCacheRequest) string { return a.ID })) } } @@ -663,12 +667,20 @@ func (p *playbackEngine) nextPlayingIndex() int { } func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { + var item mediaprovider.MediaItem + var url string + if idx >= 0 { + item = p.playQueue[idx] + url = p.getMediaURLForIdx(idx) + } + track, isTrack := item.(*mediaprovider.Track) + if p.audiocache != nil && isTrack { + p.audiocache.CacheFile(item.Metadata().ID, p.getMediaURLForIdx(idx)) + } + if urlP, ok := p.player.(player.URLPlayer); ok { - url := "" var meta mediaprovider.MediaItemMetadata if idx >= 0 { - item := p.playQueue[idx] - track, isTrack := item.(*mediaprovider.Track) meta = item.Metadata() if isTrack && p.audiocache != nil { if filepath := p.audiocache.PathForCachedFile(track.ID); filepath != "" { @@ -676,9 +688,6 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { log.Println("playing file from cache") } } - if url == "" { - url = p.getMediaURLForIdx(idx) - } if url == "" { return errors.New("no stream URL") } diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index d18f520..a1a3a7a 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -112,7 +112,6 @@ func (p *PlaybackManager) addOnTrackChangeHook() { updateUnfinishedJob := func(job *WaveformImageJob) { ctx, c := context.WithCancel(p.cache.rootCtx) refreshCancel = c - log.Println("starting img update func") go func(ctx context.Context, job *WaveformImageJob) { for { time.Sleep(333 * time.Millisecond) @@ -120,7 +119,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() { case <-ctx.Done(): return default: - log.Println("updating waveform img") + log.Println("updating waveform img, job step", job.step) img := job.Get() for _, cb := range p.onWaveformImgUpdate { cb(img) @@ -138,6 +137,7 @@ func (p *PlaybackManager) addOnTrackChangeHook() { var im *WaveformImage done := false if nextWaveformJob.ItemID == item.Metadata().ID { + log.Println("Have waveform in progress for", item.Metadata().ID) done = nextWaveformJob.Done() im = nextWaveformJob.Get() } diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go index 545ed51..8f015fc 100644 --- a/backend/util/filestreamer.go +++ b/backend/util/filestreamer.go @@ -32,11 +32,8 @@ func NewFileStreamerServer(path string, isComplete func() bool) (*FileStreamerSe done: make(chan struct{}), } - mux := http.NewServeMux() - mux.HandleFunc("/stream", fs.streamHandler) - fs.server = &http.Server{ - Handler: mux, + Handler: handler{fs}, } return fs, nil @@ -45,7 +42,7 @@ func NewFileStreamerServer(path string, isComplete func() bool) (*FileStreamerSe // Addr returns the server address (host:port). func (fs *FileStreamerServer) Addr() string { _, port, _ := net.SplitHostPort(fs.listener.Addr().String()) - return "http://localhost:" + port + "/stream" + return "http://127.0.0.1:" + port + "/" } // Serve starts serving and waits for a single request to complete. @@ -54,6 +51,7 @@ func (fs *FileStreamerServer) Serve() error { _ = fs.server.Serve(fs.listener) }() + log.Println("Serving and WAITING for done") <-fs.done // wait for the handler to finish ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -64,10 +62,15 @@ func (fs *FileStreamerServer) Serve() error { // Handler that streams the file using chunked transfer encoding. func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Request) { + log.Println("FILE STREAMER REQUEST") defer close(fs.done) // signal Serve() to shut down after this request + totalWrote := 0 + defer log.Println("File streamer wrote", totalWrote, "bytes") + file, err := os.Open(fs.Path) if err != nil { + log.Println("File streamer failed to open source file") http.Error(w, "could not open file", http.StatusInternalServerError) return } @@ -81,6 +84,7 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque buf := make([]byte, 4096) for { + complete := fs.IsComplete() n, err := file.Read(buf) if err != nil && err != io.EOF { log.Printf("read error: %v", err) @@ -88,7 +92,7 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque } if n > 0 { - _, err := w.Write(buf[:n]) + written, err := w.Write(buf[:n]) if err != nil { log.Printf("client write error: %v", err) break @@ -96,9 +100,10 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque if canFlush { flusher.Flush() } + totalWrote += written } - if n == 0 && fs.IsComplete() { + if n == 0 && complete { break } @@ -108,3 +113,13 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque } } } + +type handler struct { + fs *FileStreamerServer +} + +var _ http.Handler = handler{} + +func (h handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + h.fs.streamHandler(w, req) +} diff --git a/backend/waveformimage.go b/backend/waveformimage.go index e4dd45b..82bddb6 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -15,10 +15,10 @@ import ( "time" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/backend/util" "github.com/go-audio/audio" "github.com/go-audio/wav" "github.com/supersonic-app/go-mpv" + "golang.org/x/sys/unix" ) type WaveformImageGenerator struct { @@ -39,10 +39,13 @@ type WaveformImageJob struct { progress int // first invalid pixel in X direction done bool cancel func() + + step int } func (w *WaveformImageJob) Cancel() { if w != nil && w.cancel != nil { + w.step = -1 w.cancel() } } @@ -101,13 +104,25 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra path := w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) // wait for file to begin downloading if not already for path == "" { - time.Sleep(10 * time.Millisecond) + time.Sleep(50 * time.Millisecond) if e := ctx.Err(); e != nil { job.setError(e) return } path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) } + // and wait for content to begin being written + for { + if s, err := os.Stat(path); err == nil && s.Size() > 0 { + break + } + time.Sleep(50 * time.Millisecond) + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + } + job.step = 1 dir := filepath.Dir(path) transcodeFile := filepath.Join(dir, filepath.Base(path)+"_waveform.wav") @@ -117,25 +132,22 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra } // If file isn't fully downloaded from server, - // stream it to MPV via HTTP so it doesn't possibly + // stream it to MPV via fifo so it doesn't possibly // terminate the conversion to WAV early encountering EOF if !fileDone() { - srv, err := util.NewFileStreamerServer(path, fileDone) - if err != nil { - job.setError(err) - return - } - path = srv.Addr() - log.Println("streaming file to MPV at ", path) - go srv.Serve() + fifoPath := filepath.Join(filepath.Dir(path), filepath.Base(path)+"_fifo") + copyFileToFifo(ctx, job, path, fifoPath, fileDone) + path = fifoPath // MPV will read from the FIFO } // Start converting the file to WAV for analysis var wavConvertDone bool go func() { + job.step = 2 err := convertToWav(ctx, path, transcodeFile) wavConvertDone = true if err != nil { + log.Println("Error converting to wav", err) job.setError(err) } }() @@ -145,12 +157,13 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra if s, err := os.Stat(transcodeFile); err == nil && s.Size() > 0 { break } - time.Sleep(10 * time.Millisecond) + time.Sleep(50 * time.Millisecond) if e := ctx.Err(); e != nil { job.setError(e) return } } + job.step = 3 // Start analyzing the converted wav file data := &waveformData{} @@ -160,10 +173,14 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra log.Println("error analyzing wav", err.Error()) job.setError(err) } + data.done = true }() // Start generating the waveform image - go generateWaveformImage(ctx, data, job) + go func() { + generateWaveformImage(ctx, data, job) + job.done = true + }() }() return job } @@ -177,8 +194,6 @@ type waveformData struct { } func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) { - defer func() { job.done = true }() - centerY := job.img.Rect.Dy() / 2 // 16 top := centerY - 1 bottom := centerY @@ -194,7 +209,7 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor if ctx.Err() != nil { return // expired } - time.Sleep(10 * time.Millisecond) + time.Sleep(50 * time.Millisecond) } rms := float64(data.RMS[x]) / 255.0 @@ -222,10 +237,73 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor } } +func copyFileToFifo(ctx context.Context, job *WaveformImageJob, filePath, fifoPath string, fileDone func() bool) { + if err := unix.Mkfifo(fifoPath, 0600); err != nil { + job.setError(err) + return + } + + go func() { + fifo, err := os.OpenFile(fifoPath, os.O_WRONLY, os.ModeNamedPipe) + if err != nil { + job.setError(err) + return + } + file, err := os.Open(filePath) + if err != nil { + job.setError(err) + return + } + defer file.Close() + defer fifo.Close() + + buf := make([]byte, 4096) + offset := int64(0) + + for { + if e := ctx.Err(); e != nil { + job.setError(e) + return + } + + done := fileDone() + info, err := os.Stat(filePath) + if err != nil { + job.setError(err) + return + } + currentSize := info.Size() + + // If we've read everything available so far + if offset >= currentSize { + if done { + return // done + } + // wait a bit for more data to be written to file + time.Sleep(50 * time.Millisecond) + continue + } + + // Calculate how much is safe to read + toRead := currentSize - offset - 1 + if toRead > int64(cap(buf)) { + toRead = int64(cap(buf)) + } + + n, err := file.ReadAt(buf[:toRead], offset) + if n > 0 { + if _, werr := fifo.Write(buf[:n]); werr != nil { + job.setError(werr) + return + } + offset += int64(n) + } + } + }() +} + // assumes mono, 16 bit func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { - defer func() { data.done = true }() - f, err := os.Open(transcodeFile) if err != nil { log.Println("error opening transcoded file") @@ -254,14 +332,16 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel // file read loop - for { + doneReading := false + for !doneReading { select { case <-ctx.Done(): return ctx.Err() default: } + fileIsDone := fileDone() - if !fileDone() { + if !fileIsDone { // Check how many samples we can safely read without encountering EOF // and adjust read buffer size accordingly @@ -272,14 +352,14 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat currentSize := stat.Size() // how many bytes can we read without nearing EOF - readableBytes := currentSize - int64(samplesPerChunk)*int64(curChunk)*bytesPerSample - 16384 //buffer for safety + readableBytes := currentSize - int64(samplesPerChunk)*int64(curChunk)*bytesPerSample - 8192 //buffer for safety // Estimate how many samples we can read maxSamples := int(readableBytes / bytesPerSample) if maxSamples <= 0 { // Wait for more data to be written to file - time.Sleep(10 * time.Millisecond) + time.Sleep(50 * time.Millisecond) continue } @@ -293,13 +373,14 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat n, err := decoder.PCMBuffer(buf) if n == 0 || err == io.EOF { - if fileDone() { - break + if fileIsDone { + doneReading = true } if err == io.EOF && !fileDone() { return errors.New("WAV read got premature EOF") } - continue + } else if fileIsDone { + log.Println("read samples on done file") } if err != nil { return err @@ -320,6 +401,7 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat data.progress = curChunk chunkSamples = chunkSamples[:0] if curChunk >= 1024 { + doneReading = true break } } @@ -348,9 +430,10 @@ func computePeakAndRMS(chunk []float32) (peak float32, rms float32) { var sumSquares float64 peak = 0.0 for _, v := range chunk { - abs := float32(math.Abs(float64(v))) - if abs > peak { - peak = abs + if v > peak { + peak = v + } else if v < -peak { + peak = -v } sumSquares += float64(v * v) } @@ -394,14 +477,20 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { case <-ctx.Done(): return ctx.Err() default: - ia := m.GetPropertyString("idle-active") - if ia == "yes" || ia == "true" { - return nil - } // use small timeout to allow detecting ctx expiry // without too much delay e := m.WaitEvent(0.05 /*timeout seconds*/) if e.Event_Id == mpv.EVENT_IDLE { + if _, err := os.Stat(outPath); os.IsNotExist(err) { + log.Printf("WARNING! file %s does not exist after MPV convert", outPath) + } + return nil + } + ia := m.GetPropertyString("idle-active") + if ia == "yes" || ia == "true" { + if _, err := os.Stat(outPath); os.IsNotExist(err) { + log.Printf("WARNING! file %s does not exist after MPV convert", outPath) + } return nil } } diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 52bd07f..470fccf 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -45,7 +45,7 @@ func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { w.img.Image = img prm, fg := w.getThemeColors() w.recolorImage(prm, fg, w.imgProgressPixel) - w.Refresh() + w.img.Refresh() } func (w *WaveformSeekbar) Refresh() { From 333b96dd3b43603eb1d5ec5d1bfb80f4c3435785 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 27 Jul 2025 17:39:48 -0700 Subject: [PATCH 12/23] switch back to using HTTP server to stream file to MPV - it doesn't idle after playing FIFO --- backend/playbackmanager.go | 1 - backend/util/filestreamer.go | 10 +---- backend/waveformimage.go | 84 ++++++------------------------------ 3 files changed, 14 insertions(+), 81 deletions(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index a1a3a7a..67360c2 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -119,7 +119,6 @@ func (p *PlaybackManager) addOnTrackChangeHook() { case <-ctx.Done(): return default: - log.Println("updating waveform img, job step", job.step) img := job.Get() for _, cb := range p.onWaveformImgUpdate { cb(img) diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go index 8f015fc..ac241fa 100644 --- a/backend/util/filestreamer.go +++ b/backend/util/filestreamer.go @@ -51,7 +51,6 @@ func (fs *FileStreamerServer) Serve() error { _ = fs.server.Serve(fs.listener) }() - log.Println("Serving and WAITING for done") <-fs.done // wait for the handler to finish ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) @@ -61,13 +60,9 @@ func (fs *FileStreamerServer) Serve() error { } // Handler that streams the file using chunked transfer encoding. -func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Request) { - log.Println("FILE STREAMER REQUEST") +func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, _ *http.Request) { defer close(fs.done) // signal Serve() to shut down after this request - totalWrote := 0 - defer log.Println("File streamer wrote", totalWrote, "bytes") - file, err := os.Open(fs.Path) if err != nil { log.Println("File streamer failed to open source file") @@ -92,7 +87,7 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque } if n > 0 { - written, err := w.Write(buf[:n]) + _, err := w.Write(buf[:n]) if err != nil { log.Printf("client write error: %v", err) break @@ -100,7 +95,6 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, r *http.Reque if canFlush { flusher.Flush() } - totalWrote += written } if n == 0 && complete { diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 82bddb6..2f46bef 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -15,10 +15,10 @@ import ( "time" "github.com/dweymouth/supersonic/backend/mediaprovider" + "github.com/dweymouth/supersonic/backend/util" "github.com/go-audio/audio" "github.com/go-audio/wav" "github.com/supersonic-app/go-mpv" - "golang.org/x/sys/unix" ) type WaveformImageGenerator struct { @@ -135,9 +135,17 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra // stream it to MPV via fifo so it doesn't possibly // terminate the conversion to WAV early encountering EOF if !fileDone() { - fifoPath := filepath.Join(filepath.Dir(path), filepath.Base(path)+"_fifo") - copyFileToFifo(ctx, job, path, fifoPath, fileDone) - path = fifoPath // MPV will read from the FIFO + srv, err := util.NewFileStreamerServer(path, fileDone) + if err != nil { + job.setError(err) + return + } + + path = srv.Addr() + + log.Println("streaming file to MPV at ", path) + go srv.Serve() + time.Sleep(10 * time.Millisecond) // make sure server has time to come up } // Start converting the file to WAV for analysis @@ -237,71 +245,6 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor } } -func copyFileToFifo(ctx context.Context, job *WaveformImageJob, filePath, fifoPath string, fileDone func() bool) { - if err := unix.Mkfifo(fifoPath, 0600); err != nil { - job.setError(err) - return - } - - go func() { - fifo, err := os.OpenFile(fifoPath, os.O_WRONLY, os.ModeNamedPipe) - if err != nil { - job.setError(err) - return - } - file, err := os.Open(filePath) - if err != nil { - job.setError(err) - return - } - defer file.Close() - defer fifo.Close() - - buf := make([]byte, 4096) - offset := int64(0) - - for { - if e := ctx.Err(); e != nil { - job.setError(e) - return - } - - done := fileDone() - info, err := os.Stat(filePath) - if err != nil { - job.setError(err) - return - } - currentSize := info.Size() - - // If we've read everything available so far - if offset >= currentSize { - if done { - return // done - } - // wait a bit for more data to be written to file - time.Sleep(50 * time.Millisecond) - continue - } - - // Calculate how much is safe to read - toRead := currentSize - offset - 1 - if toRead > int64(cap(buf)) { - toRead = int64(cap(buf)) - } - - n, err := file.ReadAt(buf[:toRead], offset) - if n > 0 { - if _, werr := fifo.Write(buf[:n]); werr != nil { - job.setError(werr) - return - } - offset += int64(n) - } - } - }() -} - // assumes mono, 16 bit func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { f, err := os.Open(transcodeFile) @@ -379,8 +322,6 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat if err == io.EOF && !fileDone() { return errors.New("WAV read got premature EOF") } - } else if fileIsDone { - log.Println("read samples on done file") } if err != nil { return err @@ -401,7 +342,6 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat data.progress = curChunk chunkSamples = chunkSamples[:0] if curChunk >= 1024 { - doneReading = true break } } From 9a1325b92df85c8e4de92c90cc86d87610bb05e9 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 28 Jul 2025 18:15:12 -0700 Subject: [PATCH 13/23] fix mpv mem leak, waveform gen when double-clicking to play tracks; clean up logs --- backend/audiocache.go | 8 ++--- backend/playbackengine.go | 2 -- backend/playbackmanager.go | 71 ++++++++++++++++++++++---------------- backend/waveformimage.go | 7 ++-- 4 files changed, 48 insertions(+), 40 deletions(-) diff --git a/backend/audiocache.go b/backend/audiocache.go index fb5ba4a..48e0af6 100644 --- a/backend/audiocache.go +++ b/backend/audiocache.go @@ -100,7 +100,7 @@ func (a *AudioCache) cacheFile(id, dlURL string) { e.done = true } a.mutex.Unlock() - } else if err != context.DeadlineExceeded { + } else if err != nil && err != context.DeadlineExceeded { log.Printf("error downloading audio file: %v", err) } cancel() // release ctx resources when done @@ -120,9 +120,9 @@ func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { return a.ID == id }) { _ = e - //e.cancel() - //_ = os.Remove(a.pathForID(id)) - //delete(a.entries, id) + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) } } diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 1d5b109..5c55dd7 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -584,7 +584,6 @@ func (p *playbackEngine) cacheNextTracks() { id = np.Metadata().ID } p.audiocache.CacheOnly(id, fetch) - log.Println("fetching files", sharedutil.MapSlice(fetch, func(a AudioCacheRequest) string { return a.ID })) } } @@ -685,7 +684,6 @@ func (p *playbackEngine) setTrack(idx int, next bool, startTime float64) error { if isTrack && p.audiocache != nil { if filepath := p.audiocache.PathForCachedFile(track.ID); filepath != "" { url = filepath - log.Println("playing file from cache") } } if url == "" { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 67360c2..b1a3fa8 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -84,34 +84,44 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) - var curWaveformJob *WaveformImageJob - var nextWaveformJob *WaveformImageJob - var refreshCancel context.CancelFunc + var waveformJobs [3]*WaveformImageJob + findJob := func(id string) (*WaveformImageJob, bool) { + for _, j := range waveformJobs { + if j != nil && j.ItemID == id { + return j, true + } + } + return nil, false + } + addJob := func(job *WaveformImageJob) { + waveformJobs[0].Cancel() + waveformJobs[0] = waveformJobs[1] + waveformJobs[1] = waveformJobs[2] + waveformJobs[2] = job + } p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { - log.Println("preparing waveform image for next track ", item.Metadata().ID) - id := item.Metadata().ID - _ = p.cache.PathForCachedOrDownloadingFile(id) - - curWaveformJob.Cancel() - curWaveformJob = nextWaveformJob - nextWaveformJob = p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track)) + // start generating waveform image for next-up track + addJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) } }) + lastPlayingID := "" + var wfmImageUpdateCancel context.CancelFunc + p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { // Autoplay if enabled and we are on the last track if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } - if refreshCancel != nil { - refreshCancel() + if wfmImageUpdateCancel != nil { + wfmImageUpdateCancel() } updateUnfinishedJob := func(job *WaveformImageJob) { ctx, c := context.WithCancel(p.cache.rootCtx) - refreshCancel = c + wfmImageUpdateCancel = c go func(ctx context.Context, job *WaveformImageJob) { for { time.Sleep(333 * time.Millisecond) @@ -132,24 +142,27 @@ func (p *PlaybackManager) addOnTrackChangeHook() { } if item != nil { - log.Println("Playing track ", item.Metadata().ID) - var im *WaveformImage - done := false - if nextWaveformJob.ItemID == item.Metadata().ID { - log.Println("Have waveform in progress for", item.Metadata().ID) - done = nextWaveformJob.Done() - im = nextWaveformJob.Get() + // cancel possible waveform generation job for previous track + if old, ok := findJob(lastPlayingID); ok { + old.Cancel() } - if im != nil { - for _, cb := range p.onWaveformImgUpdate { - cb(im) - } - if !done { - updateUnfinishedJob(nextWaveformJob) - } + lastPlayingID = item.Metadata().ID + + var job *WaveformImageJob + if j, ok := findJob(item.Metadata().ID); ok { + job = j } else if tr, ok := item.(*mediaprovider.Track); ok { - curWaveformJob = p.wfmGen.StartWaveformGeneration(tr) - updateUnfinishedJob(curWaveformJob) + job = p.wfmGen.StartWaveformGeneration(tr) + addJob(job) + } + if job != nil { + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if !job.done && job != nil { + updateUnfinishedJob(job) + } } } diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 2f46bef..30aec38 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -64,7 +64,6 @@ func (w *WaveformImageJob) Err() error { func (w *WaveformImageJob) Get() *WaveformImage { if w.Done() { - log.Println("returning image directly") return w.img } // return a new *WaveformImage with data copied @@ -143,7 +142,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra path = srv.Addr() - log.Println("streaming file to MPV at ", path) go srv.Serve() time.Sleep(10 * time.Millisecond) // make sure server has time to come up } @@ -155,7 +153,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra err := convertToWav(ctx, path, transcodeFile) wavConvertDone = true if err != nil { - log.Println("Error converting to wav", err) job.setError(err) } }() @@ -178,7 +175,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra go func() { err := analyzeWavFile(ctx, transcodeFile, data, item.Duration.Milliseconds(), func() bool { return wavConvertDone }) if err != nil { - log.Println("error analyzing wav", err.Error()) job.setError(err) } data.done = true @@ -249,7 +245,6 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformData, millisecs int64, fileDone func() bool) error { f, err := os.Open(transcodeFile) if err != nil { - log.Println("error opening transcoded file") return err } defer f.Close() @@ -409,6 +404,8 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { return err } + defer m.TerminateDestroy() + m.Command([]string{"loadfile", inPath, "replace"}) // Wait for MPV idle or ctx expiry From 8f50e1087bac9bdf24d31d33116dc7cc61f980bb Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 30 Jul 2025 17:36:56 -0700 Subject: [PATCH 14/23] increase vertical resolution of waveform img, calculate with less casting --- backend/waveformimage.go | 41 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 30aec38..4965771 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -28,7 +28,7 @@ type WaveformImageGenerator struct { type WaveformImage = image.NRGBA func NewWaveformImage() *WaveformImage { - return image.NewNRGBA(image.Rect(0, 0, 1024, 32)) + return image.NewNRGBA(image.Rect(0, 0, 1024, 48)) } type WaveformImageJob struct { @@ -198,9 +198,9 @@ type waveformData struct { } func generateWaveformImage(ctx context.Context, data *waveformData, job *WaveformImageJob) { - centerY := job.img.Rect.Dy() / 2 // 16 - top := centerY - 1 - bottom := centerY + centerY := job.img.Rect.Dy() / 2 // 24 + top := centerY - 1 // 23 + bottom := centerY // 24 opaqueColor := color.NRGBA{R: 255, G: 255, B: 255, A: 255} translucentColor := color.NRGBA{R: 255, G: 255, B: 255, A: 128} @@ -216,26 +216,23 @@ func generateWaveformImage(ctx context.Context, data *waveformData, job *Wavefor time.Sleep(50 * time.Millisecond) } - rms := float64(data.RMS[x]) / 255.0 - peak := float64(data.Peak[x]) / 255.0 - - rmsPixels := int(rms * 16) - peakPixels := int((peak - rms) * 16) + rmsPixels := int(data.RMS[x]) * centerY / 255 + peakPixels := int(data.Peak[x]) * centerY / 255 // Always draw at least 2 center pixels setPixel(job.img, x, top, opaqueColor) setPixel(job.img, x, bottom, opaqueColor) // Draw RMS pixels (solid) - for i := 1; i <= rmsPixels; i++ { + for i := 1; i < rmsPixels; i++ { setPixel(job.img, x, top-i, opaqueColor) setPixel(job.img, x, bottom+i, opaqueColor) } // Draw Peak extension (translucent) - for i := 1; i <= peakPixels; i++ { - setPixel(job.img, x, top-rmsPixels-i, translucentColor) - setPixel(job.img, x, bottom+rmsPixels+i, translucentColor) + for i := max(1, rmsPixels); i < peakPixels; i++ { + setPixel(job.img, x, top-i, translucentColor) + setPixel(job.img, x, bottom+i, translucentColor) } job.progress = x + 1 } @@ -266,7 +263,7 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat buf := &audio.IntBuffer{Data: make([]int, 4096)} curChunk := 0 - chunkSamples := make([]float32, 0, samplesPerChunk) + chunkSamples := make([]float64, 0, samplesPerChunk) bytesPerSample := int64(2 * format.NumChannels) // 16-bit = 2 bytes per channel // file read loop @@ -324,14 +321,14 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat // Process samples for i := 0; i < n; i++ { - sample := float32(buf.Data[i]) / float32(1<<15) // Normalize to [-1, 1] + sample := float64(buf.Data[i]) / float64(1<<15) // Normalize to [-1, 1] chunkSamples = append(chunkSamples, sample) if len(chunkSamples) >= samplesPerChunk { if curChunk < 1024 { peak, rms := computePeakAndRMS(chunkSamples) - data.Peak[curChunk] = float32ToByte(peak) - data.RMS[curChunk] = float32ToByte(rms) + data.Peak[curChunk] = float64ToByte(peak) + data.RMS[curChunk] = float64ToByte(rms) } curChunk++ data.progress = curChunk @@ -346,8 +343,8 @@ func analyzeWavFile(ctx context.Context, transcodeFile string, data *waveformDat // analyze the last chunk if it's partially filled with samples if curChunk < 1024 && len(chunkSamples) > 0 { peak, rms := computePeakAndRMS(chunkSamples) - data.Peak[curChunk] = float32ToByte(peak) - data.RMS[curChunk] = float32ToByte(rms) + data.Peak[curChunk] = float64ToByte(peak) + data.RMS[curChunk] = float64ToByte(rms) data.progress = curChunk + 1 } @@ -361,7 +358,7 @@ func (j *WaveformImageJob) setError(err error) { j.err = err } -func computePeakAndRMS(chunk []float32) (peak float32, rms float32) { +func computePeakAndRMS(chunk []float64) (peak float64, rms float64) { var sumSquares float64 peak = 0.0 for _, v := range chunk { @@ -372,11 +369,11 @@ func computePeakAndRMS(chunk []float32) (peak float32, rms float32) { } sumSquares += float64(v * v) } - rms = float32(math.Sqrt(sumSquares / float64(len(chunk)))) + rms = math.Sqrt(sumSquares / float64(len(chunk))) return } -func float32ToByte(val float32) byte { +func float64ToByte(val float64) byte { if val > 1.0 { val = 1.0 } From e0cb2a57ac8039e19a111380363ec29d2299c9e4 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 08:37:08 -0700 Subject: [PATCH 15/23] fix appearance of waveform seek cursor --- ui/widgets/waveformseekbar.go | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 470fccf..f4145df 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -13,7 +13,6 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend" - myTheme "github.com/dweymouth/supersonic/ui/theme" ) type WaveformSeekbar struct { @@ -26,7 +25,7 @@ type WaveformSeekbar struct { imgProgressPixel int img *canvas.Image - cursor *myTheme.ThemedRectangle + cursor *canvas.Rectangle } func NewWaveformSeekbar() *WaveformSeekbar { @@ -34,10 +33,10 @@ func NewWaveformSeekbar() *WaveformSeekbar { img: &canvas.Image{ ScaleMode: canvas.ImageScaleFastest, }, - cursor: myTheme.NewThemedRectangle(theme.ColorNameForeground), + cursor: canvas.NewRectangle(color.Transparent), } - w.cursor.Hide() w.ExtendBaseWidget(w) + w.cursor.Hidden = true return w } @@ -49,22 +48,26 @@ func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { } func (w *WaveformSeekbar) Refresh() { - w.cursor.Resize(fyne.NewSize(1.5, w.Size().Height-4)) - w.cursor.Refresh() + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) prm, fg := w.getThemeColors() - if w.recolorImage(prm, fg, w.imgProgressPixel) { - w.img.Refresh() - } + w.recolorImage(prm, fg, w.imgProgressPixel) + w.recolorCursor(prm, fg, w.cursor.Position().X) + w.BaseWidget.Refresh() } var _ desktop.Hoverable = (*WaveformSeekbar)(nil) func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { + prm, fg := w.getThemeColors() + w.recolorCursor(prm, fg, e.Position.X) + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) w.cursor.Move(fyne.NewPos(e.Position.X, 2)) w.cursor.Show() } func (w *WaveformSeekbar) MouseMoved(e *desktop.MouseEvent) { + prm, fg := w.getThemeColors() + w.recolorCursor(prm, fg, e.Position.X) w.cursor.Move(fyne.NewPos(e.Position.X, 2)) } @@ -107,6 +110,15 @@ func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { return primary, foreground } +func (w *WaveformSeekbar) recolorCursor(prm, fg color.Color, posX float32) { + progress := float32(w.imgProgressPixel) / 1024 /*waveform image width*/ + if posX/w.Size().Width < progress { + w.cursor.FillColor = fg + } else { + w.cursor.FillColor = prm + } +} + func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress int) (updated bool) { if w.img.Image == nil { return false From 5209c084444c4dcf2b61ac1ecdc90175e3068f84 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 09:07:52 -0700 Subject: [PATCH 16/23] add setting to disable waveform seekbar --- backend/config.go | 10 ++- backend/playbackengine.go | 8 +- backend/playbackmanager.go | 155 ++++++++++++++++++----------------- ui/bottompanel.go | 4 +- ui/dialogs/settingsdialog.go | 8 ++ ui/mainwindow.go | 2 +- ui/widgets/playercontrols.go | 18 +++- 7 files changed, 121 insertions(+), 84 deletions(-) diff --git a/backend/config.go b/backend/config.go index 69563d8..a0a8a4d 100644 --- a/backend/config.go +++ b/backend/config.go @@ -110,8 +110,9 @@ type NowPlayingPageConfig struct { } type PlaybackConfig struct { - Autoplay bool - RepeatMode string + Autoplay bool + RepeatMode string + UseWaveformSeekbar bool } type LocalPlaybackConfig struct { @@ -239,8 +240,9 @@ func DefaultConfig(appVersionTag string) *Config { TracklistColumns: []string{"Album", "Time", "Plays"}, }, Playback: PlaybackConfig{ - Autoplay: false, - RepeatMode: "None", + Autoplay: false, + RepeatMode: "None", + UseWaveformSeekbar: true, }, LocalPlayback: LocalPlaybackConfig{ // "auto" is the name to pass to MPV for autoselecting the output device diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 5c55dd7..7373a3d 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -84,6 +84,7 @@ type playbackEngine struct { // to pass to onSongChange listeners; clear once listeners have been called lastScrobbled *mediaprovider.Track + playbackCfg *PlaybackConfig scrobbleCfg *ScrobbleConfig transcodeCfg *TranscodingConfig replayGainCfg ReplayGainConfig @@ -118,6 +119,7 @@ func NewPlaybackEngine( sm: s, audiocache: c, player: p, + playbackCfg: playbackCfg, scrobbleCfg: scrobbleCfg, transcodeCfg: transcodeCfg, nowPlayingIdx: -1, @@ -820,7 +822,11 @@ func (pm *playbackEngine) invokeNoArgCallbacks(cbs []func()) { func (p *playbackEngine) startPollTimePos() { ctx, cancel := context.WithCancel(p.ctx) p.cancelPollPos = cancel - pollingTick := time.NewTicker(125 * time.Millisecond) + pollFrequency := 250 * time.Millisecond + if p.playbackCfg.UseWaveformSeekbar { + pollFrequency = 100 * time.Millisecond + } + pollingTick := time.NewTicker(pollFrequency) go func() { for { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index b1a3fa8..b40b971 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -37,7 +37,10 @@ type PlaybackManager struct { autoplay bool - lastPlayTime float64 + lastPlayTime float64 + lastPlayingID string + wfmUpdateImageCancel context.CancelFunc + wfmImageJobs [3]*WaveformImageJob } type RemotePlaybackDevice struct { @@ -75,6 +78,22 @@ func NewPlaybackManager( return pm } +func (p *PlaybackManager) findWfmImageJob(id string) (*WaveformImageJob, bool) { + for _, j := range p.wfmImageJobs { + if j != nil && j.ItemID == id { + return j, true + } + } + return nil, false +} + +func (p *PlaybackManager) addWfmImageJob(job *WaveformImageJob) { + p.wfmImageJobs[0].Cancel() + p.wfmImageJobs[0] = p.wfmImageJobs[1] + p.wfmImageJobs[1] = p.wfmImageJobs[2] + p.wfmImageJobs[2] = job +} + func (p *PlaybackManager) addOnTrackChangeHook() { // See https://github.com/dweymouth/supersonic/issues/483 // On Windows, MPV sometimes fails to start playback when switching to a track @@ -84,87 +103,21 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.lastPlayTime = curTime }) - var waveformJobs [3]*WaveformImageJob - findJob := func(id string) (*WaveformImageJob, bool) { - for _, j := range waveformJobs { - if j != nil && j.ItemID == id { - return j, true + p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { + if p.engine.playbackCfg.UseWaveformSeekbar { + if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { + // start generating waveform image for next-up track + p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) } } - return nil, false - } - addJob := func(job *WaveformImageJob) { - waveformJobs[0].Cancel() - waveformJobs[0] = waveformJobs[1] - waveformJobs[1] = waveformJobs[2] - waveformJobs[2] = job - } - - p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { - if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { - // start generating waveform image for next-up track - addJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) - } }) - lastPlayingID := "" - var wfmImageUpdateCancel context.CancelFunc - p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { // Autoplay if enabled and we are on the last track if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 { p.enqueueAutoplayTracks() } - if wfmImageUpdateCancel != nil { - wfmImageUpdateCancel() - } - - updateUnfinishedJob := func(job *WaveformImageJob) { - ctx, c := context.WithCancel(p.cache.rootCtx) - wfmImageUpdateCancel = c - go func(ctx context.Context, job *WaveformImageJob) { - for { - time.Sleep(333 * time.Millisecond) - select { - case <-ctx.Done(): - return - default: - img := job.Get() - for _, cb := range p.onWaveformImgUpdate { - cb(img) - } - if job.Done() { - return - } - } - } - }(ctx, job) - } - - if item != nil { - // cancel possible waveform generation job for previous track - if old, ok := findJob(lastPlayingID); ok { - old.Cancel() - } - lastPlayingID = item.Metadata().ID - - var job *WaveformImageJob - if j, ok := findJob(item.Metadata().ID); ok { - job = j - } else if tr, ok := item.(*mediaprovider.Track); ok { - job = p.wfmGen.StartWaveformGeneration(tr) - addJob(job) - } - if job != nil { - img := job.Get() - for _, cb := range p.onWaveformImgUpdate { - cb(img) - } - if !job.done && job != nil { - updateUnfinishedJob(job) - } - } - } + p.handleWaveformImageSongChange(item) if runtime.GOOS != "windows" { return @@ -183,6 +136,62 @@ func (p *PlaybackManager) addOnTrackChangeHook() { }) } +func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.MediaItem) { + if p.wfmUpdateImageCancel != nil { + p.wfmUpdateImageCancel() + } + if !p.engine.playbackCfg.UseWaveformSeekbar { + return + } + + updateUnfinishedJob := func(job *WaveformImageJob) { + ctx, c := context.WithCancel(p.cache.rootCtx) + p.wfmUpdateImageCancel = c + go func(ctx context.Context, job *WaveformImageJob) { + for { + time.Sleep(333 * time.Millisecond) + select { + case <-ctx.Done(): + return + default: + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if job.Done() { + return + } + } + } + }(ctx, job) + } + + if item != nil { + // cancel possible waveform generation job for previous track + if old, ok := p.findWfmImageJob(p.lastPlayingID); ok { + old.Cancel() + } + p.lastPlayingID = item.Metadata().ID + + var job *WaveformImageJob + if j, ok := p.findWfmImageJob(item.Metadata().ID); ok { + job = j + } else if tr, ok := item.(*mediaprovider.Track); ok { + job = p.wfmGen.StartWaveformGeneration(tr) + p.addWfmImageJob(job) + } + if job != nil { + img := job.Get() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + if !job.done && job != nil { + updateUnfinishedJob(job) + } + } + } +} + func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) { if fastScan { p.scanRemotePlayers(ctx, 1 /*waitSec*/) diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 699f284..ce0a2ba 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -28,7 +28,7 @@ type BottomPanel struct { var _ fyne.Widget = (*BottomPanel)(nil) -func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller) *BottomPanel { +func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr *controller.Controller, useWaveformSeekbar bool) *BottomPanel { bp := &BottomPanel{} bp.ExtendBaseWidget(bp) @@ -86,7 +86,7 @@ func NewBottomPanel(pm *backend.PlaybackManager, im *backend.ImageManager, contr contr.ShowShareDialog(tr.ID) } } - bp.Controls = widgets.NewPlayerControls() + bp.Controls = widgets.NewPlayerControls(useWaveformSeekbar) bp.Controls.OnPlayPause(func() { pm.PlayPause() }) diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go index 8b26732..9bb0b29 100644 --- a/ui/dialogs/settingsdialog.go +++ b/ui/dialogs/settingsdialog.go @@ -559,6 +559,12 @@ func (s *SettingsDialog) createAppearanceTab(window fyne.Window) *container.TabI } } + useWaveformSeekbar := widget.NewCheck(lang.L("Use waveform seekbar"), func(b bool) { + s.config.Playback.UseWaveformSeekbar = b + s.setRestartRequired() + }) + useWaveformSeekbar.Checked = s.config.Playback.UseWaveformSeekbar + return container.NewTabItem(lang.L("Appearance"), container.NewVBox( util.NewHSpace(0), // insert a theme.Padding amount of space at top container.NewBorder(nil, nil, widget.NewLabel(lang.L("Theme")), /*left*/ @@ -570,6 +576,8 @@ func (s *SettingsDialog) createAppearanceTab(window fyne.Window) *container.TabI container.NewBorder(nil, nil, widget.NewLabel(lang.L("Grid card size")), nil, gridCardSize), disableDPI, s.newSectionSeparator(), + useWaveformSeekbar, + s.newSectionSeparator(), widget.NewRichText(&widget.TextSegment{Text: lang.L("Application font"), Style: util.BoldRichTextStyle}), container.New(layout.NewFormLayout(), widget.NewLabel(lang.L("Normal font")), container.NewBorder(nil, nil, nil, normalFontBrowse, normalFontEntry), diff --git a/ui/mainwindow.go b/ui/mainwindow.go index c77b98d..57450de 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -90,7 +90,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, )) } - m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller) + m.BottomPanel = NewBottomPanel(app.PlaybackManager, app.ImageManager, m.Controller, m.Controller.App.Config.Playback.UseWaveformSeekbar) app.PlaybackManager.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) { fyne.Do(func() { m.UpdateOnTrackChange(item) }) }) diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index 216bd26..adc07b1 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -82,6 +82,8 @@ func (t *TrackPosSlider) IsDragging() bool { type PlayerControls struct { widget.BaseWidget + UseWaveformSeekbar bool + slider *TrackPosSlider waveform *WaveformSeekbar curTimeLabel *labelMinSize @@ -112,14 +114,18 @@ func NewLabelMinSize(text string, minWidth float32) *labelMinSize { } // NewPlayerControls sets up the seek bar, and transport buttons. -func NewPlayerControls() *PlayerControls { - pc := &PlayerControls{} +func NewPlayerControls(useWaveformSeekbar bool) *PlayerControls { + pc := &PlayerControls{UseWaveformSeekbar: useWaveformSeekbar} pc.ExtendBaseWidget(pc) pc.slider = NewTrackPosSlider() pc.slider.Disable() - pc.slider.Hide() pc.waveform = NewWaveformSeekbar() + if useWaveformSeekbar { + pc.slider.Hidden = true + } else { + pc.waveform.Hidden = true + } pc.curTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) pc.curTimeLabel.Alignment = fyne.TextAlignTrailing pc.totalTimeLabel = NewLabelMinSize(util.SecondsToMMSS(0), 55) @@ -221,6 +227,12 @@ func (p *PlayerControls) UpdateWaveformImg(img *backend.WaveformImage) { p.waveform.UpdateImage(img) } +func (p *PlayerControls) Refresh() { + p.waveform.Hidden = !p.UseWaveformSeekbar + p.slider.Hidden = p.UseWaveformSeekbar + p.BaseWidget.Refresh() +} + func (p *PlayerControls) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(p.container) } From b9df60bdcd00fc2525c5c894296d610f81cf535c Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 12:50:01 -0700 Subject: [PATCH 17/23] fix UI weirdness when waveform image is being updated --- backend/playbackengine.go | 2 +- ui/widgets/waveformseekbar.go | 35 ++++++++++++++++++++--------------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/playbackengine.go b/backend/playbackengine.go index 7373a3d..fa0d15f 100644 --- a/backend/playbackengine.go +++ b/backend/playbackengine.go @@ -269,7 +269,7 @@ func (p *playbackEngine) PlaybackStatus() PlaybackStatus { return PlaybackStatus{ State: stat.State, TimePos: stat.TimePos, - Duration: stat.Duration, + Duration: p.curTrackDuration, } } diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index f4145df..19d88ac 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -41,16 +41,16 @@ func NewWaveformSeekbar() *WaveformSeekbar { } func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { - w.img.Image = img prm, fg := w.getThemeColors() - w.recolorImage(prm, fg, w.imgProgressPixel) + recolorWaveformImage(img, prm, fg, 0, w.imgProgressPixel, true) + w.img.Image = img w.img.Refresh() } func (w *WaveformSeekbar) Refresh() { w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) prm, fg := w.getThemeColors() - w.recolorImage(prm, fg, w.imgProgressPixel) + w.updateImageProgress(prm, fg, w.imgProgressPixel) w.recolorCursor(prm, fg, w.cursor.Position().X) w.BaseWidget.Refresh() } @@ -97,7 +97,7 @@ func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { func (w *WaveformSeekbar) SetProgress(v float64) { prm, fg := w.getThemeColors() thresholdPixel := int(math.Round(1024.0 /*pixel width of waveform*/ * v)) - if w.recolorImage(prm, fg, thresholdPixel) { + if w.updateImageProgress(prm, fg, thresholdPixel) { w.img.Refresh() } } @@ -119,7 +119,7 @@ func (w *WaveformSeekbar) recolorCursor(prm, fg color.Color, posX float32) { } } -func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress int) (updated bool) { +func (w *WaveformSeekbar) updateImageProgress(cL, cR color.Color, progress int) (updated bool) { if w.img.Image == nil { return false } @@ -127,29 +127,34 @@ func (w *WaveformSeekbar) recolorImage(cL, cR color.Color, progress int) (update return false } + img := w.img.Image.(*image.NRGBA) + recolorWaveformImage(img, cL, cR, w.imgProgressPixel, progress, false) + w.imgColorL, w.imgColorR = cL, cR + w.imgProgressPixel = progress + return true +} + +func recolorWaveformImage(img *image.NRGBA, cL, cR color.Color, oldProgress, newProgress int, fullRecolor bool) { _r, _g, _b, _ := cL.RGBA() rL, gL, bL := byte(_r>>8), byte(_g>>8), byte(_b>>8) _r, _g, _b, _ = cR.RGBA() rR, gR, bR := byte(_r>>8), byte(_g>>8), byte(_b>>8) - // TODO- smartly figure out which pixels we need - // to update for different scenarios (e.g. progress update only) - // and not iterate the whole thing every time - img := w.img.Image.(*image.NRGBA) bnds := img.Rect.Bounds() - for x := 0; x < bnds.Dx(); x++ { + xMin, xMax := 0, bnds.Dx() + if !fullRecolor { + xMin = min(oldProgress, newProgress) + xMax = max(oldProgress, newProgress) + } + for x := xMin; x < xMax; x++ { for y := 0; y < bnds.Dy(); y++ { - if x < progress { + if x < newProgress { setPixelRGB(img, x, y, rL, gL, bL) } else { setPixelRGB(img, x, y, rR, gR, bR) } } } - - w.imgColorL, w.imgColorR = cL, cR - w.imgProgressPixel = progress - return true } func setPixelRGB(img *image.NRGBA, x, y int, r, g, b byte) { From 8078b7082fa72663bc84a9cde951103327a262fd Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 17:44:22 -0700 Subject: [PATCH 18/23] fix out of bounds crash --- ui/widgets/waveformseekbar.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 19d88ac..955285c 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -143,8 +143,8 @@ func recolorWaveformImage(img *image.NRGBA, cL, cR color.Color, oldProgress, new bnds := img.Rect.Bounds() xMin, xMax := 0, bnds.Dx() if !fullRecolor { - xMin = min(oldProgress, newProgress) - xMax = max(oldProgress, newProgress) + xMin = max(0, min(oldProgress, newProgress)) + xMax = min(bnds.Dx(), max(oldProgress, newProgress)) } for x := xMin; x < xMax; x++ { for y := 0; y < bnds.Dy(); y++ { From 8e3ec5e11b856393ee628a35655bc78888c70398 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 17:52:05 -0700 Subject: [PATCH 19/23] use a zero waveform image with single horizontal line when none exists --- backend/playbackmanager.go | 9 +++++++++ backend/waveformimage.go | 11 ++++++++++- ui/widgets/waveformseekbar.go | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index b40b971..bb1c086 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -190,6 +190,15 @@ func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.Media } } } + + if item == nil || item.Metadata().Type != mediaprovider.MediaItemTypeTrack { + // set a zero waveform image when we're not playing anything + // or playing a media type we can't derive a waveform from (e.g. radio) + img := NewWaveformImage() + for _, cb := range p.onWaveformImgUpdate { + cb(img) + } + } } func (p *PlaybackManager) ScanRemotePlayers(ctx context.Context, fastScan bool) { diff --git a/backend/waveformimage.go b/backend/waveformimage.go index 4965771..fbfeac9 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -28,7 +28,16 @@ type WaveformImageGenerator struct { type WaveformImage = image.NRGBA func NewWaveformImage() *WaveformImage { - return image.NewNRGBA(image.Rect(0, 0, 1024, 48)) + img := image.NewNRGBA(image.Rect(0, 0, 1024, 48)) + centerTop := img.Rect.Dy() / 2 // 24 + centerBottom := centerTop - 1 // 23 + + // color in center line + for x := 0; x < img.Bounds().Dx(); x++ { + setPixel(img, x, centerTop, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + setPixel(img, x, centerBottom, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + } + return img } type WaveformImageJob struct { diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 955285c..8e5dc29 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -32,6 +32,7 @@ func NewWaveformSeekbar() *WaveformSeekbar { w := &WaveformSeekbar{ img: &canvas.Image{ ScaleMode: canvas.ImageScaleFastest, + Image: backend.NewWaveformImage(), }, cursor: canvas.NewRectangle(color.Transparent), } From 43b8b60fb07dfb221f276e50ac09b6168c6bf815 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Fri, 1 Aug 2025 17:55:36 -0700 Subject: [PATCH 20/23] make waveform seekbar non-interactive when stopped / playing radio --- ui/widgets/playercontrols.go | 3 +++ ui/widgets/waveformseekbar.go | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ui/widgets/playercontrols.go b/ui/widgets/playercontrols.go index adc07b1..82425fb 100644 --- a/ui/widgets/playercontrols.go +++ b/ui/widgets/playercontrols.go @@ -121,6 +121,7 @@ func NewPlayerControls(useWaveformSeekbar bool) *PlayerControls { pc.slider = NewTrackPosSlider() pc.slider.Disable() pc.waveform = NewWaveformSeekbar() + pc.waveform.Disable() if useWaveformSeekbar { pc.slider.Hidden = true } else { @@ -206,8 +207,10 @@ func (pc *PlayerControls) UpdatePlayTime(curTime, totalTime float64) { } if totalTime > 0 { pc.slider.Enable() + pc.waveform.Enable() } else { pc.slider.Disable() + pc.waveform.Disable() } if !pc.slider.IsDragging() { ct := util.SecondsToMMSS(curTime) diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index 8e5dc29..ad92846 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -16,7 +16,7 @@ import ( ) type WaveformSeekbar struct { - widget.BaseWidget + widget.DisableableWidget OnSeeked func(float64) @@ -59,6 +59,9 @@ func (w *WaveformSeekbar) Refresh() { var _ desktop.Hoverable = (*WaveformSeekbar)(nil) func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { + if w.Disabled() { + return + } prm, fg := w.getThemeColors() w.recolorCursor(prm, fg, e.Position.X) w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) @@ -67,6 +70,9 @@ func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { } func (w *WaveformSeekbar) MouseMoved(e *desktop.MouseEvent) { + if w.Disabled() { + return + } prm, fg := w.getThemeColors() w.recolorCursor(prm, fg, e.Position.X) w.cursor.Move(fyne.NewPos(e.Position.X, 2)) @@ -79,7 +85,7 @@ func (w *WaveformSeekbar) MouseOut() { var _ fyne.Tappable = (*WaveformSeekbar)(nil) func (w *WaveformSeekbar) Tapped(e *fyne.PointEvent) { - if w.OnSeeked != nil { + if !w.Disabled() && w.OnSeeked != nil { w.OnSeeked(float64(e.Position.X / w.Size().Width)) } } From 96b47feb76194e29f240223e669dea555dc278b0 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 3 Aug 2025 16:24:25 -0700 Subject: [PATCH 21/23] add focus/keybaord control to waveform seekbar --- ui/widgets/waveformseekbar.go | 71 +++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 8 deletions(-) diff --git a/ui/widgets/waveformseekbar.go b/ui/widgets/waveformseekbar.go index ad92846..f6e79a2 100644 --- a/ui/widgets/waveformseekbar.go +++ b/ui/widgets/waveformseekbar.go @@ -24,8 +24,11 @@ type WaveformSeekbar struct { imgColorR color.Color imgProgressPixel int + focused bool + img *canvas.Image cursor *canvas.Rectangle + focus *canvas.Rectangle } func NewWaveformSeekbar() *WaveformSeekbar { @@ -35,14 +38,16 @@ func NewWaveformSeekbar() *WaveformSeekbar { Image: backend.NewWaveformImage(), }, cursor: canvas.NewRectangle(color.Transparent), + focus: canvas.NewRectangle(color.Transparent), } w.ExtendBaseWidget(w) w.cursor.Hidden = true + w.focus.Hidden = true return w } func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { - prm, fg := w.getThemeColors() + prm, fg, _ := w.getThemeColors() recolorWaveformImage(img, prm, fg, 0, w.imgProgressPixel, true) w.img.Image = img w.img.Refresh() @@ -50,9 +55,12 @@ func (w *WaveformSeekbar) UpdateImage(img *backend.WaveformImage) { func (w *WaveformSeekbar) Refresh() { w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) - prm, fg := w.getThemeColors() + w.focus.Resize(fyne.NewSize(3, w.Size().Height-2)) + prm, fg, focus := w.getThemeColors() w.updateImageProgress(prm, fg, w.imgProgressPixel) w.recolorCursor(prm, fg, w.cursor.Position().X) + w.focus.FillColor = focus + w.BaseWidget.Refresh() } @@ -62,7 +70,7 @@ func (w *WaveformSeekbar) MouseIn(e *desktop.MouseEvent) { if w.Disabled() { return } - prm, fg := w.getThemeColors() + prm, fg, _ := w.getThemeColors() w.recolorCursor(prm, fg, e.Position.X) w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) w.cursor.Move(fyne.NewPos(e.Position.X, 2)) @@ -73,13 +81,49 @@ func (w *WaveformSeekbar) MouseMoved(e *desktop.MouseEvent) { if w.Disabled() { return } - prm, fg := w.getThemeColors() + prm, fg, _ := w.getThemeColors() w.recolorCursor(prm, fg, e.Position.X) w.cursor.Move(fyne.NewPos(e.Position.X, 2)) } func (w *WaveformSeekbar) MouseOut() { + if !w.focused { + w.cursor.Hide() + } +} + +var _ fyne.Focusable = (*WaveformSeekbar)(nil) + +func (w *WaveformSeekbar) FocusGained() { + w.focused = true + prm, fg, _ := w.getThemeColors() + w.recolorCursor(prm, fg, w.cursor.Position().X) + w.cursor.Resize(fyne.NewSize(1, w.Size().Height-4)) + w.moveCursorAndFocusToCurrentPosition() + w.cursor.Show() + w.focus.Show() +} + +func (w *WaveformSeekbar) FocusLost() { + w.focused = false w.cursor.Hide() + w.focus.Hide() +} + +func (w *WaveformSeekbar) TypedKey(e *fyne.KeyEvent) { + progress := float32(w.imgProgressPixel) / 1024 + switch e.Name { + case fyne.KeyLeft: + progress = max(progress-0.05, 0) + case fyne.KeyRight: + progress = min(progress+0.05, 1) + default: + return + } + w.Tapped(&fyne.PointEvent{Position: fyne.NewPos(w.Size().Width*progress, 0)}) +} + +func (w *WaveformSeekbar) TypedRune(r rune) { } var _ fyne.Tappable = (*WaveformSeekbar)(nil) @@ -94,7 +138,7 @@ func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer( container.NewStack( container.New(layout.NewCustomPaddedLayout(4, 4, 0, 0), w.img), - container.NewWithoutLayout(w.cursor), + container.NewWithoutLayout(w.cursor, w.focus), ), ) } @@ -102,19 +146,23 @@ func (w *WaveformSeekbar) CreateRenderer() fyne.WidgetRenderer { // SetProgress sets how much of the seekbar has been played // (ratio from 0 to 1) func (w *WaveformSeekbar) SetProgress(v float64) { - prm, fg := w.getThemeColors() + prm, fg, _ := w.getThemeColors() thresholdPixel := int(math.Round(1024.0 /*pixel width of waveform*/ * v)) if w.updateImageProgress(prm, fg, thresholdPixel) { w.img.Refresh() + if w.focused { + w.moveCursorAndFocusToCurrentPosition() + } } } -func (w *WaveformSeekbar) getThemeColors() (primary, foreground color.Color) { +func (w *WaveformSeekbar) getThemeColors() (primary, foreground, focus color.Color) { th := w.Theme() vnt := fyne.CurrentApp().Settings().ThemeVariant() primary = th.Color(theme.ColorNamePrimary, vnt) foreground = th.Color(theme.ColorNameForeground, vnt) - return primary, foreground + focus = th.Color(theme.ColorNameFocus, vnt) + return primary, foreground, focus } func (w *WaveformSeekbar) recolorCursor(prm, fg color.Color, posX float32) { @@ -126,6 +174,13 @@ func (w *WaveformSeekbar) recolorCursor(prm, fg color.Color, posX float32) { } } +func (w *WaveformSeekbar) moveCursorAndFocusToCurrentPosition() { + progress := float32(w.imgProgressPixel) / 1024 + pos := w.Size().Width * progress + w.cursor.Move(fyne.NewPos(pos, 2)) + w.focus.Move(fyne.NewPos(pos-1, 1)) +} + func (w *WaveformSeekbar) updateImageProgress(cL, cR color.Color, progress int) (updated bool) { if w.img.Image == nil { return false From 6c4435314c7c39a3b33975a3d78d1c316c3d3e78 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 4 Aug 2025 08:17:33 -0700 Subject: [PATCH 22/23] Fix bug of incomplete waveform when playing new track twice in quick succession --- backend/audiocache.go | 56 +++++++++++++++++++++++++++++++----- backend/playbackmanager.go | 20 +++++++------ backend/util/filestreamer.go | 11 +++++++ backend/waveformimage.go | 41 +++++++++++++++++--------- 4 files changed, 99 insertions(+), 29 deletions(-) diff --git a/backend/audiocache.go b/backend/audiocache.go index 48e0af6..d9d9cb8 100644 --- a/backend/audiocache.go +++ b/backend/audiocache.go @@ -26,8 +26,10 @@ type AudioCache struct { } type cacheEntry struct { - done bool - cancel context.CancelFunc + done bool + refCount int + pendingDeletion bool + cancel context.CancelFunc } // AudioCacheRequest represents a request to prefetch and cache an audio file. @@ -62,19 +64,52 @@ func (a *AudioCache) PathForCachedFile(id string) string { return "" } +// IsFullyDownloaded returns true if the file for the given id is fully downloaded. +func (a *AudioCache) IsFullyDownloaded(id string) bool { + return a.PathForCachedFile(id) != "" +} + // PathForCachedFile returns the local filesystem path for a cached track, // including one that is in the process of downloading. // If it is not cached or downloading, it returns an empty string. func (a *AudioCache) PathForCachedOrDownloadingFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, false) +} + +// ObtainReferenceToFile returns the local filesystem path for a cached track, +// including one that is in the process of downloading, and obtains a refernce +// to it such that it will not be deleted until ReleaseReferenceToFile is called. + +// If it is not cached or downloading, it returns an empty string. +func (a *AudioCache) ObtainReferenceToFile(id string) string { + return a.pathForCachedOrDownloadingFile(id, true) +} + +func (a *AudioCache) pathForCachedOrDownloadingFile(id string, obtainReference bool) string { a.mutex.Lock() defer a.mutex.Unlock() - if _, ok := a.entries[id]; ok { + if entry, ok := a.entries[id]; ok { + if obtainReference { + entry.refCount++ + } return a.pathForID(id) } return "" } +func (a *AudioCache) ReleaseReferenceToFile(id string) { + a.mutex.Lock() + defer a.mutex.Unlock() + + if e, ok := a.entries[id]; ok { + e.refCount-- + if e.refCount == 0 && e.pendingDeletion { + a.deleteEntry(id, e) + } + } +} + // CacheFile begins downloading a file (if not already downloading) and stores it // to the cache directory under its ID as filename. The download is asynchronous. func (a *AudioCache) CacheFile(id, dlURL string) { @@ -119,10 +154,11 @@ func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) { if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { return a.ID == id }) { - _ = e - e.cancel() - _ = os.Remove(a.pathForID(id)) - delete(a.entries, id) + if e.refCount == 0 { + a.deleteEntry(id, e) + } else { + e.pendingDeletion = true + } } } @@ -156,3 +192,9 @@ func (a *AudioCache) Shutdown() { func (a *AudioCache) pathForID(id string) string { return filepath.Join(a.baseCacheDir, id) } + +func (a *AudioCache) deleteEntry(id string, e *cacheEntry) { + e.cancel() + _ = os.Remove(a.pathForID(id)) + delete(a.entries, id) +} diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index bb1c086..72109f7 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -78,9 +78,9 @@ func NewPlaybackManager( return pm } -func (p *PlaybackManager) findWfmImageJob(id string) (*WaveformImageJob, bool) { +func (p *PlaybackManager) findWfmImageJob(id string, uncanceledOnly bool) (*WaveformImageJob, bool) { for _, j := range p.wfmImageJobs { - if j != nil && j.ItemID == id { + if j != nil && j.ItemID == id && (!uncanceledOnly || !j.Canceled()) { return j, true } } @@ -106,8 +106,10 @@ func (p *PlaybackManager) addOnTrackChangeHook() { p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { if p.engine.playbackCfg.UseWaveformSeekbar { if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { - // start generating waveform image for next-up track - p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) + if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok { + // start generating waveform image for next-up track + p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) + } } } }) @@ -168,13 +170,15 @@ func (p *PlaybackManager) handleWaveformImageSongChange(item mediaprovider.Media if item != nil { // cancel possible waveform generation job for previous track - if old, ok := p.findWfmImageJob(p.lastPlayingID); ok { - old.Cancel() + if p.lastPlayingID != item.Metadata().ID { + if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok { + old.Cancel() + } + p.lastPlayingID = item.Metadata().ID } - p.lastPlayingID = item.Metadata().ID var job *WaveformImageJob - if j, ok := p.findWfmImageJob(item.Metadata().ID); ok { + if j, ok := p.findWfmImageJob(item.Metadata().ID, true); ok { job = j } else if tr, ok := item.(*mediaprovider.Track); ok { job = p.wfmGen.StartWaveformGeneration(tr) diff --git a/backend/util/filestreamer.go b/backend/util/filestreamer.go index ac241fa..36e4892 100644 --- a/backend/util/filestreamer.go +++ b/backend/util/filestreamer.go @@ -77,14 +77,25 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, _ *http.Reque flusher, canFlush := w.(http.Flusher) + bytesRead := int64(0) buf := make([]byte, 4096) for { complete := fs.IsComplete() + if !complete { + if s, err := os.Stat(fs.Path); err == nil { + // make sure we don't read near EOF until file is complete + maxToRead := max(0, s.Size()-bytesRead-1024) /*safety buffer*/ + buf = buf[:min(int64(cap(buf)), maxToRead)] + } + } else { + buf = buf[:cap(buf)] + } n, err := file.Read(buf) if err != nil && err != io.EOF { log.Printf("read error: %v", err) break } + bytesRead += int64(n) if n > 0 { _, err := w.Write(buf[:n]) diff --git a/backend/waveformimage.go b/backend/waveformimage.go index fbfeac9..e065e0f 100644 --- a/backend/waveformimage.go +++ b/backend/waveformimage.go @@ -48,17 +48,22 @@ type WaveformImageJob struct { progress int // first invalid pixel in X direction done bool cancel func() - - step int + canceled bool } func (w *WaveformImageJob) Cancel() { - if w != nil && w.cancel != nil { - w.step = -1 - w.cancel() + if w != nil { + w.canceled = true + if w.cancel != nil { + w.cancel() + } } } +func (w *WaveformImageJob) Canceled() bool { + return w.canceled +} + func (w *WaveformImageJob) Done() bool { w.lock.Lock() defer w.lock.Unlock() @@ -109,7 +114,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra // 3. Begin analyzing the resulting WAV file // 4. Begin generating the image from the analysis data go func() { - path := w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + path := w.audioCache.ObtainReferenceToFile(job.ItemID) // wait for file to begin downloading if not already for path == "" { time.Sleep(50 * time.Millisecond) @@ -117,7 +122,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra job.setError(e) return } - path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) + path = w.audioCache.ObtainReferenceToFile(job.ItemID) } // and wait for content to begin being written for { @@ -130,13 +135,22 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra return } } - job.step = 1 dir := filepath.Dir(path) - transcodeFile := filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + var transcodeFile string + for i := 0; true; i++ { + if i > 0 { + transcodeFile = filepath.Join(dir, fmt.Sprintf("%s_waveform_%d.wav", filepath.Base(path), i)) + } else { + transcodeFile = filepath.Join(dir, filepath.Base(path)+"_waveform.wav") + } + if _, err := os.Stat(transcodeFile); os.IsNotExist(err) { + break // found a suitable filename that doesn't exist + } + } fileDone := func() bool { - return w.audioCache.PathForCachedFile(job.ItemID) != "" + return w.audioCache.IsFullyDownloaded(job.ItemID) } // If file isn't fully downloaded from server, @@ -158,8 +172,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra // Start converting the file to WAV for analysis var wavConvertDone bool go func() { - job.step = 2 - err := convertToWav(ctx, path, transcodeFile) + err := w.convertToWav(ctx, job.ItemID, path, transcodeFile) wavConvertDone = true if err != nil { job.setError(err) @@ -177,7 +190,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra return } } - job.step = 3 // Start analyzing the converted wav file data := &waveformData{} @@ -392,7 +404,7 @@ func float64ToByte(val float64) byte { return byte(val * 255) } -func convertToWav(ctx context.Context, inPath, outPath string) error { +func (w *WaveformImageGenerator) convertToWav(ctx context.Context, id, inPath, outPath string) error { m := mpv.Create() m.SetOptionString("video", "no") m.SetOptionString("audio-display", "no") @@ -413,6 +425,7 @@ func convertToWav(ctx context.Context, inPath, outPath string) error { defer m.TerminateDestroy() m.Command([]string{"loadfile", inPath, "replace"}) + defer w.audioCache.ReleaseReferenceToFile(id) // Wait for MPV idle or ctx expiry for { From d8ef747b8bd00b5398d5d9382ec21cdce8a2a3ed Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 4 Aug 2025 08:27:02 -0700 Subject: [PATCH 23/23] don't use audio cache to pre-download files if not using waveform seekbar --- backend/app.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/app.go b/backend/app.go index e8143ab..5e5dc1e 100644 --- a/backend/app.go +++ b/backend/app.go @@ -144,11 +144,13 @@ func StartupApp(appName, displayAppName, appVersion, appVersionTag, latestReleas a.ServerManager = NewServerManager(appName, appVersion, a.Config, !portableMode && a.Config.Application.EnablePasswordStorage) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, cacheDir) - ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, "audio")) - if err != nil { - log.Printf("failed to create audio cache: %s", err.Error()) + if a.Config.Playback.UseWaveformSeekbar { + ac, err := NewAudioCache(a.bgrndCtx, a.ServerManager, filepath.Join(cacheDir, "audio")) + if err != nil { + log.Printf("failed to create audio cache: %s", err.Error()) + } + a.AudioCache = ac } - a.AudioCache = ac a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.AudioCache, a.LocalPlayer, &a.Config.Playback, &a.Config.Scrobbling, &a.Config.Transcoding, &a.Config.Application) a.Config.Application.MaxImageCacheSizeMB = clamp(a.Config.Application.MaxImageCacheSizeMB, 1, 500) a.ImageManager.SetMaxOnDiskCacheSizeBytes(int64(a.Config.Application.MaxImageCacheSizeMB) * 1_048_576)