incremental waveform generation (kind-of) working
This commit is contained in:
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
+53
-51
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+274
-71
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user