Fix bug of incomplete waveform when playing new track twice in quick succession
This commit is contained in:
+49
-7
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user