Fix bug of incomplete waveform when playing new track twice in quick succession

This commit is contained in:
Drew Weymouth
2025-08-04 08:17:33 -07:00
parent 96b47feb76
commit 6c4435314c
4 changed files with 99 additions and 29 deletions
+49 -7
View File
@@ -26,8 +26,10 @@ type AudioCache struct {
} }
type cacheEntry struct { type cacheEntry struct {
done bool done bool
cancel context.CancelFunc refCount int
pendingDeletion bool
cancel context.CancelFunc
} }
// AudioCacheRequest represents a request to prefetch and cache an audio file. // AudioCacheRequest represents a request to prefetch and cache an audio file.
@@ -62,19 +64,52 @@ func (a *AudioCache) PathForCachedFile(id string) string {
return "" 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, // PathForCachedFile returns the local filesystem path for a cached track,
// including one that is in the process of downloading. // including one that is in the process of downloading.
// If it is not cached or downloading, it returns an empty string. // If it is not cached or downloading, it returns an empty string.
func (a *AudioCache) PathForCachedOrDownloadingFile(id string) 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() a.mutex.Lock()
defer a.mutex.Unlock() 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 a.pathForID(id)
} }
return "" 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 // 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. // 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) {
@@ -119,10 +154,11 @@ func (a *AudioCache) CacheOnly(keep string, fetch []AudioCacheRequest) {
if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool { if id != keep && !slices.ContainsFunc(fetch, func(a AudioCacheRequest) bool {
return a.ID == id return a.ID == id
}) { }) {
_ = e if e.refCount == 0 {
e.cancel() a.deleteEntry(id, e)
_ = os.Remove(a.pathForID(id)) } else {
delete(a.entries, id) e.pendingDeletion = true
}
} }
} }
@@ -156,3 +192,9 @@ func (a *AudioCache) Shutdown() {
func (a *AudioCache) pathForID(id string) string { func (a *AudioCache) pathForID(id string) string {
return filepath.Join(a.baseCacheDir, id) 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)
}
+12 -8
View File
@@ -78,9 +78,9 @@ func NewPlaybackManager(
return pm 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 { for _, j := range p.wfmImageJobs {
if j != nil && j.ItemID == id { if j != nil && j.ItemID == id && (!uncanceledOnly || !j.Canceled()) {
return j, true return j, true
} }
} }
@@ -106,8 +106,10 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) { p.engine.onBeforeSongChange = append(p.engine.onBeforeSongChange, func(item mediaprovider.MediaItem) {
if p.engine.playbackCfg.UseWaveformSeekbar { if p.engine.playbackCfg.UseWaveformSeekbar {
if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack { if p.wfmGen != nil && item != nil && item.Metadata().Type == mediaprovider.MediaItemTypeTrack {
// start generating waveform image for next-up track if _, ok := p.findWfmImageJob(item.Metadata().ID, true); !ok {
p.addWfmImageJob(p.wfmGen.StartWaveformGeneration(item.(*mediaprovider.Track))) // 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 { if item != nil {
// cancel possible waveform generation job for previous track // cancel possible waveform generation job for previous track
if old, ok := p.findWfmImageJob(p.lastPlayingID); ok { if p.lastPlayingID != item.Metadata().ID {
old.Cancel() if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok {
old.Cancel()
}
p.lastPlayingID = item.Metadata().ID
} }
p.lastPlayingID = item.Metadata().ID
var job *WaveformImageJob var job *WaveformImageJob
if j, ok := p.findWfmImageJob(item.Metadata().ID); ok { if j, ok := p.findWfmImageJob(item.Metadata().ID, true); ok {
job = j job = j
} else if tr, ok := item.(*mediaprovider.Track); ok { } else if tr, ok := item.(*mediaprovider.Track); ok {
job = p.wfmGen.StartWaveformGeneration(tr) job = p.wfmGen.StartWaveformGeneration(tr)
+11
View File
@@ -77,14 +77,25 @@ func (fs *FileStreamerServer) streamHandler(w http.ResponseWriter, _ *http.Reque
flusher, canFlush := w.(http.Flusher) flusher, canFlush := w.(http.Flusher)
bytesRead := int64(0)
buf := make([]byte, 4096) buf := make([]byte, 4096)
for { for {
complete := fs.IsComplete() 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) n, err := file.Read(buf)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
log.Printf("read error: %v", err) log.Printf("read error: %v", err)
break break
} }
bytesRead += int64(n)
if n > 0 { if n > 0 {
_, err := w.Write(buf[:n]) _, err := w.Write(buf[:n])
+27 -14
View File
@@ -48,17 +48,22 @@ type WaveformImageJob struct {
progress int // first invalid pixel in X direction progress int // first invalid pixel in X direction
done bool done bool
cancel func() cancel func()
canceled bool
step int
} }
func (w *WaveformImageJob) Cancel() { func (w *WaveformImageJob) Cancel() {
if w != nil && w.cancel != nil { if w != nil {
w.step = -1 w.canceled = true
w.cancel() if w.cancel != nil {
w.cancel()
}
} }
} }
func (w *WaveformImageJob) Canceled() bool {
return w.canceled
}
func (w *WaveformImageJob) Done() bool { func (w *WaveformImageJob) Done() bool {
w.lock.Lock() w.lock.Lock()
defer w.lock.Unlock() defer w.lock.Unlock()
@@ -109,7 +114,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra
// 3. Begin analyzing the resulting WAV file // 3. Begin analyzing the resulting WAV file
// 4. Begin generating the image from the analysis data // 4. Begin generating the image from the analysis data
go func() { go func() {
path := w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) path := w.audioCache.ObtainReferenceToFile(job.ItemID)
// wait for file to begin downloading if not already // wait for file to begin downloading if not already
for path == "" { for path == "" {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
@@ -117,7 +122,7 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra
job.setError(e) job.setError(e)
return return
} }
path = w.audioCache.PathForCachedOrDownloadingFile(job.ItemID) path = w.audioCache.ObtainReferenceToFile(job.ItemID)
} }
// and wait for content to begin being written // and wait for content to begin being written
for { for {
@@ -130,13 +135,22 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra
return return
} }
} }
job.step = 1
dir := filepath.Dir(path) 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 { fileDone := func() bool {
return w.audioCache.PathForCachedFile(job.ItemID) != "" return w.audioCache.IsFullyDownloaded(job.ItemID)
} }
// If file isn't fully downloaded from server, // 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 // Start converting the file to WAV for analysis
var wavConvertDone bool var wavConvertDone bool
go func() { go func() {
job.step = 2 err := w.convertToWav(ctx, job.ItemID, path, transcodeFile)
err := convertToWav(ctx, path, transcodeFile)
wavConvertDone = true wavConvertDone = true
if err != nil { if err != nil {
job.setError(err) job.setError(err)
@@ -177,7 +190,6 @@ func (w *WaveformImageGenerator) StartWaveformGeneration(item *mediaprovider.Tra
return return
} }
} }
job.step = 3
// Start analyzing the converted wav file // Start analyzing the converted wav file
data := &waveformData{} data := &waveformData{}
@@ -392,7 +404,7 @@ func float64ToByte(val float64) byte {
return byte(val * 255) 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 := mpv.Create()
m.SetOptionString("video", "no") m.SetOptionString("video", "no")
m.SetOptionString("audio-display", "no") m.SetOptionString("audio-display", "no")
@@ -413,6 +425,7 @@ func convertToWav(ctx context.Context, inPath, outPath string) error {
defer m.TerminateDestroy() defer m.TerminateDestroy()
m.Command([]string{"loadfile", inPath, "replace"}) m.Command([]string{"loadfile", inPath, "replace"})
defer w.audioCache.ReleaseReferenceToFile(id)
// Wait for MPV idle or ctx expiry // Wait for MPV idle or ctx expiry
for { for {