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
+47 -5
View File
@@ -27,6 +27,8 @@ type AudioCache struct {
type cacheEntry struct {
done bool
refCount int
pendingDeletion bool
cancel context.CancelFunc
}
@@ -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)
}
+8 -4
View File
@@ -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,10 +106,12 @@ 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 {
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)))
}
}
}
})
p.OnSongChange(func(item mediaprovider.MediaItem, _ *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 {
if p.lastPlayingID != item.Metadata().ID {
if old, ok := p.findWfmImageJob(p.lastPlayingID, false); ok {
old.Cancel()
}
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)
+11
View File
@@ -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])
+26 -13
View File
@@ -48,16 +48,21 @@ 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
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()
@@ -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 {