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", " ")