very WIP - proof of concept for waveform seekbar

This commit is contained in:
Drew Weymouth
2025-07-23 22:10:33 -07:00
parent dfa493501d
commit ae7cb55c8c
5 changed files with 301 additions and 6 deletions
+5 -1
View File
@@ -582,7 +582,11 @@ func (p *playbackEngine) cacheNextTracks() {
}
}
}
p.audiocache.CacheOnly(p.NowPlaying().Metadata().ID, fetch)
id := ""
if np := p.NowPlaying(); np != nil {
id = np.Metadata().ID
}
p.audiocache.CacheOnly(id, fetch)
}
}
+45 -1
View File
@@ -3,6 +3,7 @@ package backend
import (
"context"
"fmt"
"image/color"
"log"
"math/rand"
"runtime"
@@ -10,6 +11,9 @@ import (
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/theme"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/player"
"github.com/dweymouth/supersonic/backend/player/dlna"
@@ -23,6 +27,7 @@ import (
// intermediary between the frontend and various Player backends.
type PlaybackManager struct {
engine *playbackEngine
cache *AudioCache
cmdQueue *playbackCommandQueue
cfg *AppConfig
@@ -61,6 +66,7 @@ func NewPlaybackManager(
cfg: appCfg,
autoplay: playbackCfg.Autoplay,
localPlayer: p,
cache: c,
}
pm.addOnTrackChangeHook()
go pm.runCmdQueue(ctx)
@@ -76,12 +82,50 @@ func (p *PlaybackManager) addOnTrackChangeHook() {
p.lastPlayTime = curTime
})
p.OnSongChange(func(mediaprovider.MediaItem, *mediaprovider.Track) {
p.OnSongChange(func(item mediaprovider.MediaItem, _ *mediaprovider.Track) {
// Autoplay if enabled and we are on the last track
if p.autoplay && p.NowPlayingIndex() == len(p.engine.playQueue)-1 {
p.enqueueAutoplayTracks()
}
// TODO: make more permanent
if p.cache != nil && item != nil {
go func() {
log.Println("begin generating waveform image")
if path := p.cache.PathForCachedFile(item.Metadata().ID); path != "" {
t := time.Now()
wd, err := GetWaveformDataForFile(context.Background(), path)
log.Printf("generate data took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000)
if err != nil {
log.Println(err.Error())
} else {
log.Println("have waveform data")
im := NewWaveformImage()
var c color.Color
fyne.DoAndWait(func() {
c = fyne.CurrentApp().Settings().Theme().Color(
theme.ColorNamePrimary,
fyne.CurrentApp().Settings().ThemeVariant(),
)
})
log.Println("generating image...")
t := time.Now()
GenerateWaveformImage(wd, im, c)
log.Printf("generate image took %0.3f milliseconds", float64(time.Since(t).Nanoseconds())/1000000)
fyne.Do(func() {
w := fyne.CurrentApp().NewWindow("waveform")
w.SetPadded(false)
w.SetContent(canvas.NewImageFromImage(im))
w.Resize(fyne.NewSize(1024, 32))
w.Show()
})
}
} else {
log.Println("no cached file for waveform image")
}
}()
}
if runtime.GOOS != "windows" {
return
}
+235
View File
@@ -0,0 +1,235 @@
package backend
import (
"context"
"errors"
"image"
"image/color"
"io"
"math"
"os"
"path/filepath"
"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 WaveformImage = image.NRGBA
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
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
}
for x := 0; x < 1024; x++ {
rms := float64(data.RMS[x]) / 255.0
peak := float64(data.Peak[x]) / 255.0
rmsPixels := int(rms * 16)
peakPixels := int((peak - rms) * 16)
// Always draw at least 2 center pixels
setPixel(imgbuf, x, top, opaqueColor)
setPixel(imgbuf, 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)
}
// Draw Peak extension (translucent)
for i := 1; i <= peakPixels; i++ {
setPixel(imgbuf, x, top-rmsPixels-i, translucentColor)
setPixel(imgbuf, x, bottom+rmsPixels+i, translucentColor)
}
}
}
func GetWaveformDataForFile(ctx context.Context, fpath string) (*WaveformData, error) {
dir := filepath.Dir(fpath)
transcodeFile := filepath.Join(dir, filepath.Base(fpath)+"_waveform.wav")
err := convertToWav(ctx, fpath, transcodeFile)
if err != nil {
return nil, err
}
f, err := os.Open(transcodeFile)
if err != nil {
return nil, err
}
defer f.Close()
defer os.Remove(transcodeFile)
decoder := wav.NewDecoder(f)
if !decoder.IsValidFile() {
return nil, errors.New("invalid wav file")
}
dur, err := decoder.Duration()
if err != nil {
return nil, err
}
format := decoder.Format()
totalSamples := format.SampleRate * int(dur.Milliseconds()) / 1000
samplesPerChunk := totalSamples / 1024
if err := decoder.FwdToPCM(); err != nil {
return nil, err
}
buf := &audio.IntBuffer{Data: make([]int, 4096)}
data := &WaveformData{}
curChunk := 0
chunkSamples := make([]float32, 0, samplesPerChunk)
for {
n, err := decoder.PCMBuffer(buf)
if n == 0 || err == io.EOF {
break
}
if err != nil {
return data, err
}
// Process samples
for i := 0; i < n; i += format.NumChannels {
sum := 0
for c := 0; c < format.NumChannels; c++ {
sum += buf.Data[i+c]
}
avg := float64(sum) / float64(format.NumChannels)
// TODO: this assumes 16 bit
sample := float32(avg / float64(1<<15)) // Normalize to [-1, 1]
chunkSamples = append(chunkSamples, sample)
if len(chunkSamples) >= samplesPerChunk {
if curChunk < 1024 {
peak, rms := computePeakAndRMS(chunkSamples)
data.Peak[curChunk] = float32ToByte(peak)
data.RMS[curChunk] = float32ToByte(rms)
}
curChunk++
chunkSamples = chunkSamples[:0]
if curChunk >= 1024 {
break
}
}
}
}
// Optionally fill the last chunk if it's partially filled
if curChunk < 1024 && len(chunkSamples) > 0 {
peak, rms := computePeakAndRMS(chunkSamples)
data.Peak[curChunk] = float32ToByte(peak)
data.RMS[curChunk] = float32ToByte(rms)
}
return data, nil
}
func computePeakAndRMS(chunk []float32) (peak float32, rms float32) {
var sumSquares float64
peak = 0.0
for _, v := range chunk {
abs := float32(math.Abs(float64(v)))
if abs > peak {
peak = abs
}
sumSquares += float64(v * v)
}
rms = float32(math.Sqrt(sumSquares / float64(len(chunk))))
return
}
func float32ToByte(val float32) byte {
if val > 1.0 {
val = 1.0
}
if val < 0.0 {
val = 0.0
}
return byte(val * 255)
}
func convertToWav(ctx context.Context, inPath, outPath string) error {
m := mpv.Create()
m.SetOptionString("video", "no")
m.SetOptionString("audio-display", "no")
m.SetOptionString("terminal", "no")
m.SetOptionString("idle", "yes")
m.SetOptionString("ao-pcm-file", outPath)
m.SetOptionString("ao", "pcm")
m.SetOption("volume", mpv.FORMAT_INT64, 100)
// no need to preserve full sample resolution just for waveform image
// let's make less data to process and smaller on-disk file
m.SetOption("audio-samplerate", mpv.FORMAT_INT64, 22050)
m.SetOptionString("audio-channels", "mono")
m.SetOptionString("audio-format", "s16")
if err := m.Initialize(); err != nil {
return err
}
m.Command([]string{"loadfile", inPath, "replace"})
//log.Println("generating wav file from %s using MPV", inPath)
return mpvWaitForIdle(ctx, m)
}
func setPixel(img *image.NRGBA, x, y int, c color.NRGBA) {
if x < 0 || x >= img.Bounds().Dx() || y < 0 || y >= img.Bounds().Dy() {
return
}
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 {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
ia := m.GetPropertyString("idle-active")
if ia == "yes" || ia == "true" {
return nil
}
// use small timeout to allow detecting ctx expiry
// without too much delay
e := m.WaitEvent(0.1 /*timeout seconds*/)
if e.Event_Id == mpv.EVENT_IDLE {
return nil
}
}
}
}