chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
package player
|
||||
|
||||
// AudioDevice represents an available audio output device (sink/endpoint).
|
||||
type AudioDevice struct {
|
||||
Index int
|
||||
Name string // internal identifier (sink name, UID, or device ID)
|
||||
Description string // human-readable label
|
||||
Active bool // true when this is the current default
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//go:build linux
|
||||
|
||||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListAudioDevices returns available output sinks via pactl.
|
||||
// Works on PulseAudio and PipeWire (via pipewire-pulse).
|
||||
func ListAudioDevices() ([]AudioDevice, error) {
|
||||
defaultSink := ""
|
||||
if out, err := exec.Command("pactl", "get-default-sink").Output(); err == nil {
|
||||
defaultSink = strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
out, err := exec.Command("pactl", "list", "sinks").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pactl: %w (is PulseAudio/PipeWire running?)", err)
|
||||
}
|
||||
|
||||
var devices []AudioDevice
|
||||
var cur *AudioDevice
|
||||
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Sink #") {
|
||||
idx, _ := strconv.Atoi(strings.TrimPrefix(line, "Sink #"))
|
||||
devices = append(devices, AudioDevice{Index: idx})
|
||||
cur = &devices[len(devices)-1]
|
||||
} else if cur != nil {
|
||||
if key, val, ok := strings.Cut(line, ": "); ok {
|
||||
switch key {
|
||||
case "Name":
|
||||
cur.Name = val
|
||||
cur.Active = val == defaultSink
|
||||
case "Description":
|
||||
cur.Description = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// PrepareAudioDevice sets PIPEWIRE_NODE so the PipeWire ALSA plugin
|
||||
// routes this process's audio to the named device.
|
||||
// Must be called before player.New(). Returns a cleanup that restores the env.
|
||||
func PrepareAudioDevice(device string) func() {
|
||||
prev, hadPrev := os.LookupEnv("PIPEWIRE_NODE")
|
||||
os.Setenv("PIPEWIRE_NODE", device)
|
||||
return func() {
|
||||
if hadPrev {
|
||||
os.Setenv("PIPEWIRE_NODE", prev)
|
||||
} else {
|
||||
os.Unsetenv("PIPEWIRE_NODE")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchAudioDevice moves cliamp's audio stream to a different sink.
|
||||
// Falls back to changing the system default if the stream can't be found.
|
||||
func SwitchAudioDevice(deviceName string) error {
|
||||
out, err := exec.Command("pactl", "list", "sink-inputs").Output()
|
||||
if err != nil {
|
||||
return fmt.Errorf("pactl: %w", err)
|
||||
}
|
||||
|
||||
pidStr := strconv.Itoa(os.Getpid())
|
||||
sinkInputIdx := -1
|
||||
currentIdx := 0
|
||||
props := map[string]string{}
|
||||
|
||||
// Parse all sink-inputs, collecting properties per entry.
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Sink Input #") {
|
||||
// Check previous entry before moving on.
|
||||
if sinkInputIdx < 0 {
|
||||
sinkInputIdx = matchCliamp(props, pidStr, currentIdx)
|
||||
}
|
||||
if sinkInputIdx >= 0 {
|
||||
break
|
||||
}
|
||||
currentIdx, _ = strconv.Atoi(strings.TrimPrefix(line, "Sink Input #"))
|
||||
props = map[string]string{}
|
||||
continue
|
||||
}
|
||||
if key, val, ok := strings.Cut(line, "="); ok {
|
||||
props[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(val), `"`)
|
||||
}
|
||||
}
|
||||
// Check the last entry.
|
||||
if sinkInputIdx < 0 {
|
||||
sinkInputIdx = matchCliamp(props, pidStr, currentIdx)
|
||||
}
|
||||
|
||||
if sinkInputIdx >= 0 {
|
||||
cmd := exec.Command("pactl", "move-sink-input",
|
||||
strconv.Itoa(sinkInputIdx), deviceName)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("move-sink-input: %s (%w)", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback: change the system default sink.
|
||||
if out, err := exec.Command("pactl", "set-default-sink", deviceName).CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("set-default-sink: %s (%w)", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchCliamp checks if a sink-input's properties belong to cliamp.
|
||||
func matchCliamp(props map[string]string, pidStr string, idx int) int {
|
||||
if props["application.process.id"] == pidStr {
|
||||
return idx
|
||||
}
|
||||
if strings.EqualFold(props["application.process.binary"], "cliamp") {
|
||||
return idx
|
||||
}
|
||||
if strings.Contains(strings.ToLower(props["application.name"]), "cliamp") {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// player/audio_device_macos.go — macOS Core Audio output device enumeration & selection.
|
||||
|
||||
//go:build darwin && !ios
|
||||
|
||||
package player
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework CoreAudio -framework CoreFoundation
|
||||
#include <CoreAudio/CoreAudio.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static AudioDeviceID caDefaultOutput() {
|
||||
AudioObjectPropertyAddress addr = {
|
||||
kAudioHardwarePropertyDefaultOutputDevice,
|
||||
kAudioObjectPropertyScopeGlobal,
|
||||
kAudioObjectPropertyElementMain,
|
||||
};
|
||||
AudioDeviceID id = kAudioObjectUnknown;
|
||||
UInt32 sz = sizeof(id);
|
||||
AudioObjectGetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, &sz, &id);
|
||||
return id;
|
||||
}
|
||||
|
||||
static int caSetDefaultOutput(AudioDeviceID id) {
|
||||
AudioObjectPropertyAddress addr = {
|
||||
kAudioHardwarePropertyDefaultOutputDevice,
|
||||
kAudioObjectPropertyScopeGlobal,
|
||||
kAudioObjectPropertyElementMain,
|
||||
};
|
||||
return AudioObjectSetPropertyData(kAudioObjectSystemObject, &addr, 0, NULL, sizeof(id), &id) == noErr ? 0 : -1;
|
||||
}
|
||||
|
||||
static int caOutputChannels(AudioDeviceID id) {
|
||||
AudioObjectPropertyAddress addr = {
|
||||
kAudioDevicePropertyStreamConfiguration,
|
||||
kAudioObjectPropertyScopeOutput,
|
||||
kAudioObjectPropertyElementMain,
|
||||
};
|
||||
UInt32 sz = 0;
|
||||
if (AudioObjectGetPropertyDataSize(id, &addr, 0, NULL, &sz) != noErr || sz == 0) return 0;
|
||||
AudioBufferList *bl = (AudioBufferList *)malloc(sz);
|
||||
if (AudioObjectGetPropertyData(id, &addr, 0, NULL, &sz, bl) != noErr) { free(bl); return 0; }
|
||||
int ch = 0;
|
||||
for (UInt32 i = 0; i < bl->mNumberBuffers; i++) ch += bl->mBuffers[i].mNumberChannels;
|
||||
free(bl);
|
||||
return ch;
|
||||
}
|
||||
|
||||
static char* caStr(CFStringRef s) {
|
||||
if (s == NULL) return NULL;
|
||||
CFIndex len = CFStringGetMaximumSizeForEncoding(CFStringGetLength(s), kCFStringEncodingUTF8)+1;
|
||||
char *buf = (char*)malloc(len);
|
||||
CFStringGetCString(s, buf, len, kCFStringEncodingUTF8);
|
||||
CFRelease(s);
|
||||
return buf;
|
||||
}
|
||||
|
||||
static char* caDevName(AudioDeviceID id) {
|
||||
AudioObjectPropertyAddress a = {kAudioObjectPropertyName, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
|
||||
CFStringRef s = NULL; UInt32 sz = sizeof(s);
|
||||
if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL;
|
||||
return caStr(s);
|
||||
}
|
||||
|
||||
static char* caDevUID(AudioDeviceID id) {
|
||||
AudioObjectPropertyAddress a = {kAudioDevicePropertyDeviceUID, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
|
||||
CFStringRef s = NULL; UInt32 sz = sizeof(s);
|
||||
if (AudioObjectGetPropertyData(id, &a, 0, NULL, &sz, &s) != noErr) return NULL;
|
||||
return caStr(s);
|
||||
}
|
||||
|
||||
typedef struct { AudioDeviceID id; char *name; char *uid; } CADev;
|
||||
|
||||
static int caListOutputs(CADev **out, int *count) {
|
||||
AudioObjectPropertyAddress a = {kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain};
|
||||
UInt32 sz = 0;
|
||||
if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &a, 0, NULL, &sz) != noErr) { *count=0; return -1; }
|
||||
int n = sz / sizeof(AudioDeviceID);
|
||||
AudioDeviceID *ids = (AudioDeviceID*)malloc(sz);
|
||||
if (AudioObjectGetPropertyData(kAudioObjectSystemObject, &a, 0, NULL, &sz, ids) != noErr) { free(ids); *count=0; return -1; }
|
||||
int oc = 0;
|
||||
for (int i=0; i<n; i++) { if (caOutputChannels(ids[i])>0) oc++; }
|
||||
CADev *devs = (CADev*)calloc(oc, sizeof(CADev));
|
||||
int j = 0;
|
||||
for (int i=0; i<n && j<oc; i++) {
|
||||
if (caOutputChannels(ids[i])<=0) continue;
|
||||
devs[j].id = ids[i]; devs[j].name = caDevName(ids[i]); devs[j].uid = caDevUID(ids[i]); j++;
|
||||
}
|
||||
free(ids); *out = devs; *count = oc;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void caFreeDevs(CADev *devs, int count) {
|
||||
for (int i=0; i<count; i++) { free(devs[i].name); free(devs[i].uid); }
|
||||
free(devs);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// ListAudioDevices enumerates Core Audio output devices.
|
||||
func ListAudioDevices() ([]AudioDevice, error) {
|
||||
var cdevs *C.CADev
|
||||
var count C.int
|
||||
if C.caListOutputs(&cdevs, &count) != 0 {
|
||||
return nil, fmt.Errorf("Core Audio: failed to enumerate devices")
|
||||
}
|
||||
defer C.caFreeDevs(cdevs, count)
|
||||
|
||||
defaultID := C.caDefaultOutput()
|
||||
slice := unsafe.Slice(cdevs, int(count))
|
||||
devices := make([]AudioDevice, int(count))
|
||||
for i, d := range slice {
|
||||
devices[i] = AudioDevice{
|
||||
Index: int(d.id),
|
||||
Name: C.GoString(d.uid),
|
||||
Description: C.GoString(d.name),
|
||||
Active: d.id == defaultID,
|
||||
}
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// PrepareAudioDevice temporarily changes the macOS system default output
|
||||
// device so that the audio engine (oto/Core Audio) picks it up during init.
|
||||
// Returns a cleanup function that restores the original default.
|
||||
func PrepareAudioDevice(device string) func() {
|
||||
devices, err := ListAudioDevices()
|
||||
if err != nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
var targetID C.AudioDeviceID
|
||||
found := false
|
||||
for _, d := range devices {
|
||||
if strings.EqualFold(d.Name, device) || strings.EqualFold(d.Description, device) {
|
||||
targetID = C.AudioDeviceID(d.Index)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
savedID := C.caDefaultOutput()
|
||||
if C.caSetDefaultOutput(targetID) != 0 {
|
||||
return func() {}
|
||||
}
|
||||
return func() { C.caSetDefaultOutput(savedID) }
|
||||
}
|
||||
|
||||
// SwitchAudioDevice changes the macOS system default output.
|
||||
// Note: the running audio stream keeps its original device; the change
|
||||
// takes full effect on the next app restart.
|
||||
func SwitchAudioDevice(deviceName string) error {
|
||||
devices, err := ListAudioDevices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, d := range devices {
|
||||
if strings.EqualFold(d.Name, deviceName) || strings.EqualFold(d.Description, deviceName) {
|
||||
if C.caSetDefaultOutput(C.AudioDeviceID(d.Index)) != 0 {
|
||||
return fmt.Errorf("Core Audio: failed to set output to %q", deviceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("device %q not found", deviceName)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// player/audio_device_stub.go — fallback for platforms without device selection support.
|
||||
|
||||
//go:build !linux && (!darwin || ios || !cgo) && !windows
|
||||
|
||||
package player
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ListAudioDevices is not available on this platform.
|
||||
func ListAudioDevices() ([]AudioDevice, error) {
|
||||
return nil, fmt.Errorf("audio device selection is not available on this platform")
|
||||
}
|
||||
|
||||
// PrepareAudioDevice is a no-op on unsupported platforms.
|
||||
func PrepareAudioDevice(device string) func() { return func() {} }
|
||||
|
||||
// SwitchAudioDevice is not supported on this platform.
|
||||
func SwitchAudioDevice(deviceName string) error {
|
||||
return fmt.Errorf("audio device switching is not available on this platform")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build windows
|
||||
|
||||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ListAudioDevices lists audio output devices via PowerShell on Windows.
|
||||
func ListAudioDevices() ([]AudioDevice, error) {
|
||||
// Use Get-CimInstance Win32_SoundDevice for basic sound card enumeration.
|
||||
script := `Get-CimInstance Win32_SoundDevice | ForEach-Object { $_.Name + '|' + $_.DeviceID }`
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("powershell: %w", err)
|
||||
}
|
||||
|
||||
var devices []AudioDevice
|
||||
for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, id, _ := strings.Cut(line, "|")
|
||||
devices = append(devices, AudioDevice{
|
||||
Index: i,
|
||||
Name: strings.TrimSpace(id),
|
||||
Description: strings.TrimSpace(name),
|
||||
Active: false,
|
||||
})
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// PrepareAudioDevice is a no-op on Windows — the system default output
|
||||
// device is used. Returns a no-op cleanup.
|
||||
func PrepareAudioDevice(device string) func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
// SwitchAudioDevice changes the Windows system default output device.
|
||||
// The running audio stream keeps its original device; the change
|
||||
// takes full effect on the next app restart.
|
||||
func SwitchAudioDevice(deviceName string) error {
|
||||
script := fmt.Sprintf(
|
||||
`Get-AudioDevice -PlaybackCommunication | Out-Null; `+
|
||||
`Set-AudioDevice -ID '%s' -ErrorAction Stop`,
|
||||
strings.ReplaceAll(deviceName, "'", "''"),
|
||||
)
|
||||
if out, err := exec.Command("powershell", "-NoProfile", "-Command", script).CombinedOutput(); err != nil {
|
||||
// AudioDeviceCmdlets may not be installed; fall back to nircmd.
|
||||
cmd := exec.Command("nircmd", "setdefaultsounddevice", deviceName)
|
||||
if out2, err2 := cmd.CombinedOutput(); err2 != nil {
|
||||
return fmt.Errorf("failed to set output device (install AudioDeviceCmdlets or nircmd): %s / %s",
|
||||
strings.TrimSpace(string(out)), strings.TrimSpace(string(out2)))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/jfreymuth/oggvorbis"
|
||||
)
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ io.ReadCloser = noCloseReader{}
|
||||
_ beep.StreamSeekCloser = (*chainedOggStreamer)(nil)
|
||||
)
|
||||
|
||||
// noCloseReader wraps an io.Reader with a Close that is a no-op.
|
||||
// This prevents the underlying HTTP body from being closed when we
|
||||
// re-initialize the decoder for a new logical bitstream.
|
||||
type noCloseReader struct{ io.Reader }
|
||||
|
||||
func (noCloseReader) Close() error { return nil }
|
||||
|
||||
// chainedOggStreamer handles chained OGG/Vorbis streams (e.g., Icecast radio).
|
||||
// When the current decoder hits EOS (end of a logical bitstream), it
|
||||
// re-initializes the vorbis decoder on the same underlying reader to
|
||||
// continue with the next song — achieving seamless chain transitions.
|
||||
//
|
||||
// Unlike the beep/vorbis.Decode wrapper, this uses jfreymuth/oggvorbis
|
||||
// directly so it can read Vorbis comment headers (TITLE, ARTIST) at each
|
||||
// chain boundary and report them via the onMeta callback.
|
||||
type chainedOggStreamer struct {
|
||||
rc io.ReadCloser // underlying HTTP body (stays open across chains)
|
||||
reader *oggvorbis.Reader // current logical bitstream
|
||||
raw *rawVorbisStreamer // raw audio from reader
|
||||
format beep.Format
|
||||
targetSR beep.SampleRate
|
||||
resampleQuality int
|
||||
stream beep.Streamer // raw or resampled (fed to gapless)
|
||||
onMeta func(string) // called with "Artist - Title" on chain
|
||||
err error
|
||||
}
|
||||
|
||||
func newChainedOggStreamer(rc io.ReadCloser, targetSR beep.SampleRate, resampleQuality int, onMeta func(string)) (*chainedOggStreamer, beep.Format, error) {
|
||||
reader, err := oggvorbis.NewReader(noCloseReader{rc})
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
|
||||
cs := &chainedOggStreamer{
|
||||
rc: rc,
|
||||
targetSR: targetSR,
|
||||
resampleQuality: resampleQuality,
|
||||
onMeta: onMeta,
|
||||
}
|
||||
cs.initDecoder(reader)
|
||||
cs.notifyMeta()
|
||||
|
||||
return cs, cs.format, nil
|
||||
}
|
||||
|
||||
// initDecoder sets up the raw decoder and resampler for a new logical bitstream.
|
||||
func (cs *chainedOggStreamer) initDecoder(reader *oggvorbis.Reader) {
|
||||
cs.reader = reader
|
||||
|
||||
channels := min(reader.Channels(), 2)
|
||||
cs.format = beep.Format{
|
||||
SampleRate: beep.SampleRate(reader.SampleRate()),
|
||||
NumChannels: channels,
|
||||
Precision: 2,
|
||||
}
|
||||
|
||||
left, right := vorbisChannelIndices(reader.Channels())
|
||||
cs.raw = &rawVorbisStreamer{
|
||||
reader: reader,
|
||||
tmp: make([]float32, reader.Channels()),
|
||||
left: left,
|
||||
right: right,
|
||||
}
|
||||
|
||||
var s beep.Streamer = cs.raw
|
||||
if cs.format.SampleRate != cs.targetSR {
|
||||
s = beep.Resample(cs.resampleQuality, cs.format.SampleRate, cs.targetSR, s)
|
||||
}
|
||||
cs.stream = s
|
||||
}
|
||||
|
||||
// notifyMeta extracts ARTIST/TITLE from Vorbis comments and fires onMeta.
|
||||
func (cs *chainedOggStreamer) notifyMeta() {
|
||||
if cs.onMeta == nil {
|
||||
return
|
||||
}
|
||||
title := vorbisCommentTitle(cs.reader.CommentHeader().Comments)
|
||||
if title != "" {
|
||||
cs.onMeta(title)
|
||||
}
|
||||
}
|
||||
|
||||
// Stream fills the sample buffer, chaining to the next logical bitstream
|
||||
// when the current one is exhausted. It always fills the full buffer for
|
||||
// live radio — this prevents the gapless streamer from treating a partial
|
||||
// fill at a chain boundary as track exhaustion.
|
||||
func (cs *chainedOggStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
total := 0
|
||||
for total < len(samples) {
|
||||
n, ok := cs.stream.Stream(samples[total:])
|
||||
total += n
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
// Stream exhausted — chain to the next logical bitstream.
|
||||
if err := cs.chain(); err != nil {
|
||||
cs.err = err
|
||||
break
|
||||
}
|
||||
}
|
||||
return total, total > 0
|
||||
}
|
||||
|
||||
// chain re-initializes the decoder for the next logical bitstream in the
|
||||
// chained OGG stream and extracts its Vorbis comment metadata.
|
||||
func (cs *chainedOggStreamer) chain() error {
|
||||
reader, err := oggvorbis.NewReader(noCloseReader{cs.rc})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cs.initDecoder(reader)
|
||||
cs.notifyMeta()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cs *chainedOggStreamer) Err() error {
|
||||
if cs.err != nil {
|
||||
return cs.err
|
||||
}
|
||||
return cs.raw.err
|
||||
}
|
||||
|
||||
// Len returns 0 — live streams have no known length.
|
||||
func (cs *chainedOggStreamer) Len() int { return 0 }
|
||||
|
||||
// Position returns 0 — live streams are not seekable.
|
||||
func (cs *chainedOggStreamer) Position() int { return 0 }
|
||||
|
||||
// Seek is a no-op for live streams.
|
||||
func (cs *chainedOggStreamer) Seek(int) error { return nil }
|
||||
|
||||
// Close closes the underlying HTTP body.
|
||||
func (cs *chainedOggStreamer) Close() error {
|
||||
return cs.rc.Close()
|
||||
}
|
||||
|
||||
// rawVorbisStreamer reads audio samples from an oggvorbis.Reader.
|
||||
// It mirrors beep's vorbis decoder but gives us direct access to the
|
||||
// underlying reader for Vorbis comment extraction.
|
||||
type rawVorbisStreamer struct {
|
||||
reader *oggvorbis.Reader
|
||||
tmp []float32 // per-frame buffer (channels wide)
|
||||
left, right int // channel indices
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *rawVorbisStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
if s.err != nil {
|
||||
return 0, false
|
||||
}
|
||||
var n int
|
||||
for i := range samples {
|
||||
dn, err := s.reader.Read(s.tmp)
|
||||
if dn == 0 {
|
||||
break
|
||||
}
|
||||
if dn < len(s.tmp) {
|
||||
break // partial frame — treat as EOS
|
||||
}
|
||||
samples[i][0] = float64(s.tmp[s.left])
|
||||
samples[i][1] = float64(s.tmp[s.right])
|
||||
n++
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
s.err = err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return n, n > 0
|
||||
}
|
||||
|
||||
func (s *rawVorbisStreamer) Err() error { return s.err }
|
||||
|
||||
// vorbisChannelIndices returns the left and right channel indices for
|
||||
// the given channel count, matching the Vorbis I spec channel mapping.
|
||||
func vorbisChannelIndices(channels int) (left, right int) {
|
||||
switch channels {
|
||||
case 1:
|
||||
return 0, 0
|
||||
case 2, 4:
|
||||
return 0, 1
|
||||
default:
|
||||
return 0, 2
|
||||
}
|
||||
}
|
||||
|
||||
// vorbisCommentTitle extracts a display title from Vorbis comment fields.
|
||||
// Returns "Artist - Title" if both are present, just the title otherwise.
|
||||
func vorbisCommentTitle(comments []string) string {
|
||||
var artist, title string
|
||||
for _, c := range comments {
|
||||
k, v, ok := strings.Cut(c, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToUpper(k) {
|
||||
case "TITLE":
|
||||
title = v
|
||||
case "ARTIST":
|
||||
artist = v
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
return ""
|
||||
}
|
||||
if artist != "" {
|
||||
return artist + " - " + title
|
||||
}
|
||||
return title
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/sshurl"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/gopxl/beep/v2/flac"
|
||||
"github.com/gopxl/beep/v2/mp3"
|
||||
"github.com/gopxl/beep/v2/vorbis"
|
||||
"github.com/gopxl/beep/v2/wav"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/httpclient"
|
||||
)
|
||||
|
||||
// SupportedExts is the set of file extensions the player can decode.
|
||||
var SupportedExts = map[string]bool{
|
||||
".mp3": true,
|
||||
".wav": true,
|
||||
".flac": true,
|
||||
".ogg": true,
|
||||
".m4a": true,
|
||||
".aac": true,
|
||||
".aacp": true,
|
||||
".m4b": true,
|
||||
".alac": true,
|
||||
".wma": true,
|
||||
".opus": true,
|
||||
".webm": true,
|
||||
}
|
||||
|
||||
// httpClient is the shared streaming HTTP client. See internal/httpclient
|
||||
// for configuration rationale (no overall timeout, HTTP/2 disabled for Icecast).
|
||||
var httpClient = httpclient.Streaming
|
||||
|
||||
// isURL reports whether path is an HTTP or HTTPS URL.
|
||||
func isURL(path string) bool {
|
||||
return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://")
|
||||
}
|
||||
|
||||
// isSSH reports whether path is an SSH remote path (ssh://host/path).
|
||||
func isSSH(path string) bool {
|
||||
return strings.HasPrefix(path, "ssh://")
|
||||
}
|
||||
|
||||
// sshReadCloser wraps an SSH subprocess stdout pipe as an io.ReadCloser.
|
||||
// Closing it kills the SSH process and reaps the child.
|
||||
type sshReadCloser struct {
|
||||
pipe io.ReadCloser // cmd.StdoutPipe()
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (s *sshReadCloser) Read(p []byte) (int, error) {
|
||||
return s.pipe.Read(p)
|
||||
}
|
||||
|
||||
func (s *sshReadCloser) Close() error {
|
||||
// Kill the SSH process if still running.
|
||||
if s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
_ = s.pipe.Close()
|
||||
waitErr := s.cmd.Wait() // reap zombie
|
||||
// Process.Kill causes Wait to return "signal: killed" — that's expected.
|
||||
if waitErr != nil {
|
||||
if exitErr, ok := waitErr.(*exec.ExitError); ok && exitErr.ExitCode() != -1 {
|
||||
return fmt.Errorf("ssh: %w", waitErr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuoteSSH wraps a string in single quotes for safe use in a remote shell command.
|
||||
// Single quotes inside the string are escaped as '\” (end quote, escaped quote, start quote).
|
||||
func shellQuoteSSH(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// openSSHSource opens a remote file via SSH by running "ssh host cat remotePath"
|
||||
// and returning the stdout pipe as an io.ReadCloser.
|
||||
// Path format: ssh://hostname/absolute/path/to/file
|
||||
func openSSHSource(path string) (sourceResult, error) {
|
||||
parsed, err := sshurl.Parse(path)
|
||||
if err != nil {
|
||||
return sourceResult{}, err
|
||||
}
|
||||
|
||||
catCmd := "cat -- " + shellQuoteSSH(parsed.Path)
|
||||
args := parsed.SSHArgs()
|
||||
args = append(args, catCmd)
|
||||
cmd := exec.Command("ssh", args...)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return sourceResult{}, fmt.Errorf("ssh stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return sourceResult{}, fmt.Errorf("ssh start: %w", err)
|
||||
}
|
||||
rc := &sshReadCloser{pipe: stdout, cmd: cmd}
|
||||
return sourceResult{body: rc, contentLength: -1}, nil
|
||||
}
|
||||
|
||||
// matchCustomURI returns the StreamerFactory for the given path if it matches
|
||||
// a registered custom URI scheme prefix, or nil if no scheme matches.
|
||||
func (p *Player) matchCustomURI(path string) StreamerFactory {
|
||||
for scheme, factory := range p.customFactories {
|
||||
if strings.HasPrefix(path, scheme) {
|
||||
return factory
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sourceResult holds the opened stream and optional HTTP metadata.
|
||||
type sourceResult struct {
|
||||
body io.ReadCloser
|
||||
contentType string // e.g. "audio/aacp"; empty for local files
|
||||
contentLength int64 // -1 if unknown; from Content-Length header for HTTP
|
||||
}
|
||||
|
||||
// openSourceAt opens a ReadCloser for the given path, handling both
|
||||
// local files and HTTP URLs.
|
||||
// offset using an HTTP Range request (Range: bytes=offset-). For local files
|
||||
// the offset is ignored (use decoder.Seek for local files).
|
||||
func openSourceAt(path string, byteOffset int64, onMeta func(string)) (sourceResult, error) {
|
||||
if isSSH(path) {
|
||||
return openSSHSource(path)
|
||||
}
|
||||
if !isURL(path) {
|
||||
f, err := os.Open(path)
|
||||
return sourceResult{body: f, contentLength: -1}, err
|
||||
}
|
||||
req, err := http.NewRequest("GET", path, nil)
|
||||
if err != nil {
|
||||
return sourceResult{}, fmt.Errorf("http request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
|
||||
// Request ICY metadata — servers that don't support it simply ignore this header.
|
||||
req.Header.Set("Icy-MetaData", "1")
|
||||
if byteOffset > 0 {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", byteOffset))
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return sourceResult{}, fmt.Errorf("http get: %w", err)
|
||||
}
|
||||
// Accept 200 OK (full response) or 206 Partial Content (range response).
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return sourceResult{}, fmt.Errorf("http status %s", resp.Status)
|
||||
}
|
||||
|
||||
body := resp.Body
|
||||
|
||||
// Wrap in ICY reader if the server provides a metaint interval.
|
||||
if metaStr := resp.Header.Get("Icy-Metaint"); metaStr != "" && onMeta != nil {
|
||||
if metaInt, err := strconv.Atoi(metaStr); err == nil && metaInt > 0 {
|
||||
body = newIcyReader(body, metaInt, onMeta)
|
||||
}
|
||||
}
|
||||
|
||||
return sourceResult{
|
||||
body: body,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
contentLength: resp.ContentLength,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extFromContentType maps an HTTP Content-Type to a file extension.
|
||||
// Returns "" if the type is unrecognized.
|
||||
func extFromContentType(ct string) string {
|
||||
// Strip parameters (e.g. "audio/aacp; charset=utf-8" → "audio/aacp").
|
||||
if i := strings.IndexByte(ct, ';'); i >= 0 {
|
||||
ct = ct[:i]
|
||||
}
|
||||
ct = strings.TrimSpace(strings.ToLower(ct))
|
||||
switch ct {
|
||||
case "audio/aac", "audio/aacp", "audio/x-aac":
|
||||
return ".aac"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return ".mp3"
|
||||
case "audio/ogg", "application/ogg":
|
||||
return ".ogg"
|
||||
case "audio/flac":
|
||||
return ".flac"
|
||||
case "audio/wav", "audio/x-wav":
|
||||
return ".wav"
|
||||
case "audio/mp4", "audio/x-m4a":
|
||||
return ".m4a"
|
||||
case "audio/opus":
|
||||
return ".opus"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatExt returns the audio format extension for a path.
|
||||
// For URLs, it parses the path component (ignoring query params),
|
||||
// checks a "format" query param as fallback, and defaults to ".mp3".
|
||||
func formatExt(path string) string {
|
||||
if !isURL(path) {
|
||||
return strings.ToLower(filepath.Ext(path))
|
||||
}
|
||||
u, err := url.Parse(path)
|
||||
if err != nil {
|
||||
return ".mp3"
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(u.Path))
|
||||
if ext == "" || ext == ".view" {
|
||||
if f := u.Query().Get("format"); f != "" {
|
||||
return "." + strings.ToLower(f)
|
||||
}
|
||||
return ".mp3"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// needsFFmpeg reports whether the given extension requires ffmpeg to decode.
|
||||
func needsFFmpeg(ext string) bool {
|
||||
switch ext {
|
||||
case ".m4a", ".aac", ".aacp", ".m4b", ".alac", ".wma", ".opus", ".webm":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHLS reports whether the extension denotes an HLS playlist that ffmpeg must
|
||||
// open by URL (so it can fetch and demux the segments itself).
|
||||
func isHLS(ext string) bool { return ext == ".m3u8" }
|
||||
|
||||
// isBufferedURL reports whether the given URL requires the buffered download
|
||||
// + ffmpeg pipeline. Returns true if a registered matcher matches the URL.
|
||||
func (p *Player) isBufferedURL(path string) bool {
|
||||
if p.bufferedURLMatch == nil {
|
||||
return false
|
||||
}
|
||||
return p.bufferedURLMatch(path)
|
||||
}
|
||||
|
||||
// decodeWithExt selects the decoder using an explicit extension.
|
||||
func decodeWithExt(rc io.ReadCloser, ext, path string, sr beep.SampleRate, bitDepth int) (beep.StreamSeekCloser, beep.Format, error) {
|
||||
if needsFFmpeg(ext) {
|
||||
return decodeFFmpeg(path, sr, bitDepth)
|
||||
}
|
||||
switch ext {
|
||||
case ".wav":
|
||||
return wav.Decode(rc)
|
||||
case ".flac":
|
||||
return flac.Decode(rc)
|
||||
case ".ogg":
|
||||
return vorbis.Decode(rc)
|
||||
default:
|
||||
return mp3.Decode(rc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package player
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsHLS(t *testing.T) {
|
||||
if !isHLS(".m3u8") {
|
||||
t.Error(".m3u8 should be HLS")
|
||||
}
|
||||
for _, ext := range []string{".mp3", ".m3u", ".aac", ""} {
|
||||
if isHLS(ext) {
|
||||
t.Errorf("%q should not be HLS", ext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsFFmpeg(t *testing.T) {
|
||||
tests := []struct {
|
||||
ext string
|
||||
want bool
|
||||
}{
|
||||
{ext: ".aac", want: true},
|
||||
{ext: ".aacp", want: true},
|
||||
{ext: ".opus", want: true},
|
||||
{ext: ".mp3", want: false},
|
||||
{ext: ".ogg", want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := needsFFmpeg(tt.ext); got != tt.want {
|
||||
t.Errorf("needsFFmpeg(%q) = %v, want %v", tt.ext, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportedExtsIncludesAACP(t *testing.T) {
|
||||
if !SupportedExts[".aacp"] {
|
||||
t.Fatal("SupportedExts[.aacp] = false, want true")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// player/device_darwin.go — macOS Core Audio device sample rate detection.
|
||||
//
|
||||
// Queries the system's default output audio device for its nominal sample rate.
|
||||
// USB audio devices (e.g., USB-C headphones/DACs) commonly operate at 48 kHz,
|
||||
// while the built-in speakers default to 44.1 kHz. Using the device's native
|
||||
// rate avoids silent playback failures where AudioQueue can't route to a USB
|
||||
// device that doesn't support the requested rate.
|
||||
|
||||
//go:build darwin && !ios
|
||||
|
||||
package player
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework CoreAudio
|
||||
#include <CoreAudio/CoreAudio.h>
|
||||
|
||||
// defaultOutputSampleRate queries the default output device's nominal sample rate.
|
||||
// Returns 0 on any error.
|
||||
static double defaultOutputSampleRate() {
|
||||
AudioObjectPropertyAddress addr;
|
||||
UInt32 size;
|
||||
OSStatus status;
|
||||
|
||||
// 1. Get the default output device.
|
||||
addr.mSelector = kAudioHardwarePropertyDefaultOutputDevice;
|
||||
addr.mScope = kAudioObjectPropertyScopeGlobal;
|
||||
addr.mElement = kAudioObjectPropertyElementMain;
|
||||
|
||||
AudioDeviceID deviceID = kAudioObjectUnknown;
|
||||
size = sizeof(deviceID);
|
||||
status = AudioObjectGetPropertyData(
|
||||
kAudioObjectSystemObject, &addr, 0, NULL, &size, &deviceID);
|
||||
if (status != noErr || deviceID == kAudioObjectUnknown) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 2. Get the device's nominal sample rate.
|
||||
addr.mSelector = kAudioDevicePropertyNominalSampleRate;
|
||||
addr.mScope = kAudioObjectPropertyScopeOutput;
|
||||
|
||||
Float64 sampleRate = 0;
|
||||
size = sizeof(sampleRate);
|
||||
status = AudioObjectGetPropertyData(deviceID, &addr, 0, NULL, &size, &sampleRate);
|
||||
if (status != noErr) {
|
||||
return 0;
|
||||
}
|
||||
return sampleRate;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
// DeviceSampleRate returns the nominal sample rate of the system's default
|
||||
// output audio device, or 0 if detection fails. Callers should fall back
|
||||
// to a sensible default (e.g. 44100) when 0 is returned.
|
||||
func DeviceSampleRate() int {
|
||||
rate := C.defaultOutputSampleRate()
|
||||
if rate <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(rate)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// player/device_other.go — stub for non-macOS platforms.
|
||||
|
||||
//go:build !darwin || ios || !cgo
|
||||
|
||||
package player
|
||||
|
||||
// DeviceSampleRate returns 0 on platforms where device detection is not
|
||||
// implemented. Callers should fall back to a sensible default (e.g. 44100).
|
||||
func DeviceSampleRate() int {
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package player
|
||||
|
||||
import "time"
|
||||
|
||||
// Engine is the interface used by the TUI model to control audio playback.
|
||||
// It is satisfied by *Player and can be replaced with a mock for testing.
|
||||
type Engine interface {
|
||||
// Playback control
|
||||
Play(path string, knownDuration time.Duration) error
|
||||
PlayYTDL(pageURL string, knownDuration time.Duration) error
|
||||
Preload(path string, knownDuration time.Duration) error
|
||||
PreloadYTDL(pageURL string, knownDuration time.Duration) error
|
||||
ClearPreload()
|
||||
Stop()
|
||||
Close()
|
||||
TogglePause()
|
||||
|
||||
// Seeking
|
||||
Seek(d time.Duration) error
|
||||
SeekYTDL(d time.Duration) error
|
||||
CancelSeekYTDL()
|
||||
|
||||
// State queries
|
||||
IsPlaying() bool
|
||||
IsPaused() bool
|
||||
Drained() bool
|
||||
HasPreload() bool
|
||||
Seekable() bool
|
||||
IsStreamSeek() bool
|
||||
IsYTDLSeek() bool
|
||||
GaplessAdvanced() bool
|
||||
|
||||
// Position and duration
|
||||
Position() time.Duration
|
||||
Duration() time.Duration
|
||||
PositionAndDuration() (time.Duration, time.Duration)
|
||||
|
||||
// Volume
|
||||
SetVolumeMin(db float64)
|
||||
VolumeMin() float64
|
||||
SetVolume(db float64)
|
||||
Volume() float64
|
||||
|
||||
// Speed
|
||||
SetSpeed(ratio float64)
|
||||
Speed() float64
|
||||
|
||||
// Mono
|
||||
ToggleMono()
|
||||
Mono() bool
|
||||
|
||||
// EQ
|
||||
SetEQBand(band int, dB float64)
|
||||
EQBands() [10]float64
|
||||
|
||||
// Stream info
|
||||
StreamErr() error
|
||||
StreamTitle() string
|
||||
StreamBytes() (downloaded, total int64)
|
||||
|
||||
// Audio samples for visualizer
|
||||
SamplesInto(dst []float64) int
|
||||
SampleRate() int
|
||||
}
|
||||
|
||||
// Compile-time check that *Player satisfies Engine.
|
||||
var _ Engine = (*Player)(nil)
|
||||
@@ -0,0 +1,87 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// eqFreqs are the center frequencies for the 10-band parametric equalizer.
|
||||
var eqFreqs = [10]float64{70, 180, 320, 600, 1000, 3000, 6000, 12000, 14000, 16000}
|
||||
|
||||
// biquad implements a second-order IIR peaking equalizer per the Audio EQ Cookbook.
|
||||
// Each filter reads its gain from a shared pointer, so EQ changes take
|
||||
// effect on the next Stream() call without rebuilding the pipeline.
|
||||
type biquad struct {
|
||||
s beep.Streamer
|
||||
freq float64
|
||||
q float64
|
||||
gain *atomic.Uint64 // points to Player.eqBands[i], stores Float64bits
|
||||
sr float64
|
||||
// Per-channel filter state
|
||||
x1, x2 [2]float64
|
||||
y1, y2 [2]float64
|
||||
// Cached coefficients
|
||||
lastGain float64
|
||||
b0, b1, b2, a1, a2 float64
|
||||
inited bool
|
||||
}
|
||||
|
||||
func newBiquad(s beep.Streamer, freq, q float64, gain *atomic.Uint64, sr float64) *biquad {
|
||||
return &biquad{s: s, freq: freq, q: q, gain: gain, sr: sr}
|
||||
}
|
||||
|
||||
func (b *biquad) calcCoeffs(dB float64) {
|
||||
if b.inited && dB == b.lastGain {
|
||||
return
|
||||
}
|
||||
b.lastGain = dB
|
||||
b.inited = true
|
||||
|
||||
a := math.Pow(10, dB/40)
|
||||
w0 := 2 * math.Pi * b.freq / b.sr
|
||||
sinW0 := math.Sin(w0)
|
||||
cosW0 := math.Cos(w0)
|
||||
alpha := sinW0 / (2 * b.q)
|
||||
|
||||
b0 := 1 + alpha*a
|
||||
b1 := -2 * cosW0
|
||||
b2 := 1 - alpha*a
|
||||
a0 := 1 + alpha/a
|
||||
a1 := -2 * cosW0
|
||||
a2 := 1 - alpha/a
|
||||
|
||||
b.b0 = b0 / a0
|
||||
b.b1 = b1 / a0
|
||||
b.b2 = b2 / a0
|
||||
b.a1 = a1 / a0
|
||||
b.a2 = a2 / a0
|
||||
}
|
||||
|
||||
func (b *biquad) Stream(samples [][2]float64) (int, bool) {
|
||||
n, ok := b.s.Stream(samples)
|
||||
dB := math.Float64frombits(b.gain.Load())
|
||||
|
||||
// Skip processing when gain is effectively zero
|
||||
if dB > -0.1 && dB < 0.1 {
|
||||
return n, ok
|
||||
}
|
||||
|
||||
b.calcCoeffs(dB)
|
||||
|
||||
for i := range n {
|
||||
for ch := range 2 {
|
||||
x := samples[i][ch]
|
||||
y := b.b0*x + b.b1*b.x1[ch] + b.b2*b.x2[ch] - b.a1*b.y1[ch] - b.a2*b.y2[ch]
|
||||
b.x2[ch] = b.x1[ch]
|
||||
b.x1[ch] = x
|
||||
b.y2[ch] = b.y1[ch]
|
||||
b.y1[ch] = y
|
||||
samples[i][ch] = y
|
||||
}
|
||||
}
|
||||
return n, ok
|
||||
}
|
||||
|
||||
func (b *biquad) Err() error { return b.s.Err() }
|
||||
@@ -0,0 +1,102 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBiquadPassthroughAtZeroDB(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{0.7, -0.3}, count: 4}
|
||||
var gain atomic.Uint64
|
||||
gain.Store(math.Float64bits(0.0))
|
||||
|
||||
b := newBiquad(src, 1000, 0.707, &gain, 44100)
|
||||
|
||||
samples := make([][2]float64, 4)
|
||||
n, _ := b.Stream(samples)
|
||||
|
||||
for i := range n {
|
||||
if math.Abs(samples[i][0]-0.7) > 1e-9 {
|
||||
t.Errorf("samples[%d][0] = %f, want 0.7", i, samples[i][0])
|
||||
}
|
||||
if math.Abs(samples[i][1]-(-0.3)) > 1e-9 {
|
||||
t.Errorf("samples[%d][1] = %f, want -0.3", i, samples[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBiquadNonZeroGainModifiesSamples(t *testing.T) {
|
||||
// Sine wave at center frequency — DC input would pass through unchanged.
|
||||
const sr = 44100
|
||||
const freq = 1000.0
|
||||
const nSamples = 512
|
||||
src := &sineStreamer{freq: freq, sr: sr, count: nSamples}
|
||||
var gain atomic.Uint64
|
||||
gain.Store(math.Float64bits(12.0)) // +12 dB boost
|
||||
|
||||
b := newBiquad(src, freq, 0.707, &gain, sr)
|
||||
|
||||
samples := make([][2]float64, nSamples)
|
||||
n, _ := b.Stream(samples)
|
||||
|
||||
maxAmp := 0.0
|
||||
for i := 256; i < n; i++ {
|
||||
if a := math.Abs(samples[i][0]); a > maxAmp {
|
||||
maxAmp = a
|
||||
}
|
||||
}
|
||||
if maxAmp <= 1.05 {
|
||||
t.Errorf("biquad at +12dB: max amplitude = %f, expected > 1.05", maxAmp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBiquadCoeffCaching(t *testing.T) {
|
||||
var gain atomic.Uint64
|
||||
gain.Store(math.Float64bits(3.0))
|
||||
|
||||
b := newBiquad(&fakeStreamer{count: 0}, 1000, 0.707, &gain, 44100)
|
||||
|
||||
b.calcCoeffs(3.0)
|
||||
b0First := b.b0
|
||||
if !b.inited {
|
||||
t.Fatal("inited should be true after calcCoeffs")
|
||||
}
|
||||
|
||||
b.calcCoeffs(3.0)
|
||||
if b.b0 != b0First {
|
||||
t.Error("coefficients should be cached for same gain")
|
||||
}
|
||||
|
||||
b.calcCoeffs(6.0)
|
||||
if b.b0 == b0First {
|
||||
t.Error("coefficients should be recomputed for different gain")
|
||||
}
|
||||
if b.lastGain != 6.0 {
|
||||
t.Errorf("lastGain = %f, want 6.0", b.lastGain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBiquadErr(t *testing.T) {
|
||||
src := &fakeStreamer{}
|
||||
var gain atomic.Uint64
|
||||
|
||||
b := newBiquad(src, 1000, 0.707, &gain, 44100)
|
||||
|
||||
if err := b.Err(); err != nil {
|
||||
t.Errorf("Err() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEqFreqs(t *testing.T) {
|
||||
for i := 1; i < len(eqFreqs); i++ {
|
||||
if eqFreqs[i] <= eqFreqs[i-1] {
|
||||
t.Errorf("eqFreqs[%d] (%f) <= eqFreqs[%d] (%f)", i, eqFreqs[i], i-1, eqFreqs[i-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Should have exactly 10 bands
|
||||
if len(eqFreqs) != 10 {
|
||||
t.Errorf("len(eqFreqs) = %d, want 10", len(eqFreqs))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// pcmFrameSize16 is the byte size of one stereo s16le sample frame (2 channels × 2 bytes).
|
||||
const pcmFrameSize16 = 4
|
||||
|
||||
// pcmFrameSize32 is the byte size of one stereo f32le sample frame (2 channels × 4 bytes).
|
||||
const pcmFrameSize32 = 8
|
||||
|
||||
// ffmpegPipeTimeout limits how long URL streams may take to produce initial PCM.
|
||||
const ffmpegPipeTimeout = 15 * time.Second
|
||||
|
||||
// pcmFrameSize returns the byte size of one stereo sample frame for the given format.
|
||||
func pcmFrameSize(f32 bool) int {
|
||||
if f32 {
|
||||
return pcmFrameSize32
|
||||
}
|
||||
return pcmFrameSize16
|
||||
}
|
||||
|
||||
// decodePCMFrame decodes one stereo sample frame from buf into a [2]float64.
|
||||
func decodePCMFrame(buf []byte, f32 bool) [2]float64 {
|
||||
if f32 {
|
||||
return [2]float64{
|
||||
float64(math.Float32frombits(binary.LittleEndian.Uint32(buf[0:4]))),
|
||||
float64(math.Float32frombits(binary.LittleEndian.Uint32(buf[4:8]))),
|
||||
}
|
||||
}
|
||||
left := int16(binary.LittleEndian.Uint16(buf[0:2]))
|
||||
right := int16(binary.LittleEndian.Uint16(buf[2:4]))
|
||||
return [2]float64{float64(left) / 32768, float64(right) / 32768}
|
||||
}
|
||||
|
||||
// streamFromReader is the shared Stream() implementation for all pipe-based
|
||||
// PCM streamers. It reads frames from a buffered reader, decodes them, and
|
||||
// records the first non-EOF error.
|
||||
func streamFromReader(reader *bufio.Reader, samples [][2]float64, buf []byte, f32 bool, errp *error) (int, bool) {
|
||||
fs := pcmFrameSize(f32)
|
||||
n := 0
|
||||
for i := range samples {
|
||||
_, err := io.ReadFull(reader, buf[:fs])
|
||||
if err != nil {
|
||||
if err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
*errp = err
|
||||
}
|
||||
break
|
||||
}
|
||||
samples[i] = decodePCMFrame(buf[:fs], f32)
|
||||
n++
|
||||
}
|
||||
return n, n > 0
|
||||
}
|
||||
|
||||
// decodeFFmpeg uses ffmpeg to decode any audio file into raw PCM,
|
||||
// returning a seekable beep.StreamSeekCloser.
|
||||
// bitDepth selects the output format: 16 (s16le) or 32 (f32le, lossless).
|
||||
func decodeFFmpeg(path string, sr beep.SampleRate, bitDepth int) (beep.StreamSeekCloser, beep.Format, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
ext := filepath.Ext(path)
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required to play %s files — install it with your package manager", ext)
|
||||
}
|
||||
|
||||
pcmFmt, codec, precision := ffmpegPCMArgs(bitDepth)
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", path,
|
||||
"-f", pcmFmt,
|
||||
"-acodec", codec,
|
||||
"-ar", strconv.Itoa(int(sr)),
|
||||
"-ac", "2",
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// cmd.Output captures stderr into ExitError.Stderr; surface it since
|
||||
// ffmpeg writes the actual failure reason there (-loglevel error).
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg decode: %w: %s", err, bytes.TrimSpace(ee.Stderr))
|
||||
}
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg decode: %w", err)
|
||||
}
|
||||
|
||||
format := beep.Format{
|
||||
SampleRate: sr,
|
||||
NumChannels: 2,
|
||||
Precision: precision,
|
||||
}
|
||||
|
||||
return &pcmStreamer{data: out, f32: bitDepth == 32}, format, nil
|
||||
}
|
||||
|
||||
// pcmStreamer wraps raw stereo PCM data (s16le or f32le) as a beep.StreamSeekCloser.
|
||||
type pcmStreamer struct {
|
||||
data []byte
|
||||
pos int // current sample frame index
|
||||
f32 bool // true = f32le (32-bit float), false = s16le (16-bit int)
|
||||
}
|
||||
|
||||
func (p *pcmStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
fs := pcmFrameSize(p.f32)
|
||||
totalFrames := len(p.data) / fs
|
||||
|
||||
if p.pos >= totalFrames {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
n := 0
|
||||
for i := range samples {
|
||||
if p.pos >= totalFrames {
|
||||
break
|
||||
}
|
||||
off := p.pos * fs
|
||||
samples[i] = decodePCMFrame(p.data[off:off+fs], p.f32)
|
||||
p.pos++
|
||||
n++
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (p *pcmStreamer) Err() error { return nil }
|
||||
|
||||
func (p *pcmStreamer) Len() int {
|
||||
return len(p.data) / pcmFrameSize(p.f32)
|
||||
}
|
||||
|
||||
func (p *pcmStreamer) Position() int {
|
||||
return p.pos
|
||||
}
|
||||
|
||||
func (p *pcmStreamer) Seek(pos int) error {
|
||||
if pos < 0 || pos > p.Len() {
|
||||
return fmt.Errorf("seek position %d out of range [0, %d]", pos, p.Len())
|
||||
}
|
||||
p.pos = pos
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *pcmStreamer) Close() error {
|
||||
p.data = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeFFmpegStream starts ffmpeg as a subprocess and streams PCM data
|
||||
// incrementally from its stdout pipe. Unlike decodeFFmpeg, this does not
|
||||
// wait for the entire input to be read — suitable for live/infinite streams.
|
||||
func decodeFFmpegStream(path string, sr beep.SampleRate, bitDepth int) (*ffmpegPipeStreamer, beep.Format, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
ext := filepath.Ext(path)
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required to play %s files — install it with your package manager", ext)
|
||||
}
|
||||
fp, format, err := startFFmpegPipe(path, nil, sr, bitDepth)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return &ffmpegPipeStreamer{ffmpegPipe: fp}, format, nil
|
||||
}
|
||||
|
||||
// startFFmpegPipe launches ffmpeg transcoding input to raw PCM on stdout and
|
||||
// returns an ffmpegPipe reading that stdout. input is the ffmpeg -i argument
|
||||
// (a URL/path, or "pipe:0" when feeding via stdin); stdin, when non-nil, is
|
||||
// wired to the process. Callers add the concrete Seek behavior by embedding the
|
||||
// returned ffmpegPipe in a streamer type.
|
||||
func startFFmpegPipe(input string, stdin io.ReadCloser, sr beep.SampleRate, bitDepth int) (ffmpegPipe, beep.Format, error) {
|
||||
pcmFmt, codec, precision := ffmpegPCMArgs(bitDepth)
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", input,
|
||||
"-f", pcmFmt,
|
||||
"-acodec", codec,
|
||||
"-ar", strconv.Itoa(int(sr)),
|
||||
"-ac", "2",
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return ffmpegPipe{}, beep.Format{}, fmt.Errorf("ffmpeg stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return ffmpegPipe{}, beep.Format{}, fmt.Errorf("ffmpeg start: %w", err)
|
||||
}
|
||||
|
||||
fp := ffmpegPipe{cmd: cmd, reader: bufio.NewReaderSize(pipe, pipeBufSize), pipe: pipe, src: stdin, f32: bitDepth == 32}
|
||||
format := beep.Format{SampleRate: sr, NumChannels: 2, Precision: precision}
|
||||
return fp, format, nil
|
||||
}
|
||||
|
||||
// ffmpegPipe holds the common state and methods shared by all pipe-based
|
||||
// ffmpeg streamers. Each concrete streamer embeds this and adds its own
|
||||
// Seek (and optionally start) implementation.
|
||||
type ffmpegPipe struct {
|
||||
cmd *exec.Cmd
|
||||
reader *bufio.Reader
|
||||
pipe io.ReadCloser
|
||||
src io.ReadCloser // optional stdin source (e.g. ICY-wrapped body); closed on stop
|
||||
buf [pcmFrameSize32]byte // large enough for both 16-bit and 32-bit frames
|
||||
f32 bool // true = f32le, false = s16le
|
||||
err error
|
||||
pos int // current sample frame position
|
||||
total int // total frames (0 if unknown/unbounded)
|
||||
}
|
||||
|
||||
func (f *ffmpegPipe) Stream(samples [][2]float64) (int, bool) {
|
||||
n, ok := streamFromReader(f.reader, samples, f.buf[:], f.f32, &f.err)
|
||||
f.pos += n
|
||||
return n, ok
|
||||
}
|
||||
|
||||
func (f *ffmpegPipe) Err() error { return f.err }
|
||||
func (f *ffmpegPipe) Len() int { return f.total }
|
||||
func (f *ffmpegPipe) Position() int { return f.pos }
|
||||
|
||||
// stop kills the running ffmpeg process and cleans up. When stdin is fed from
|
||||
// src, src is closed first: os/exec runs a goroutine copying src -> ffmpeg
|
||||
// stdin, and cmd.Wait() blocks until it returns. For an infinite radio stream
|
||||
// that goroutine is parked in src.Read, so src must be closed to unblock it
|
||||
// before Wait, otherwise stop hangs.
|
||||
func (f *ffmpegPipe) stop() {
|
||||
src := f.src
|
||||
pipe := f.pipe
|
||||
cmd := f.cmd
|
||||
f.src = nil
|
||||
f.pipe = nil
|
||||
f.cmd = nil
|
||||
|
||||
if src != nil {
|
||||
src.Close()
|
||||
}
|
||||
if pipe != nil {
|
||||
pipe.Close()
|
||||
}
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *ffmpegPipe) Close() error {
|
||||
f.stop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *ffmpegPipe) bitDepth() int {
|
||||
if f.f32 {
|
||||
return 32
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
// waitForInitialAudio waits until ffmpeg has produced at least one PCM byte.
|
||||
// This runs before a URL stream is handed to the speaker, so an idle or broken
|
||||
// live stream cannot park the audio goroutine in Read and block future swaps.
|
||||
func (f *ffmpegPipe) waitForInitialAudio(timeout time.Duration) error {
|
||||
peekErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := f.reader.Peek(1)
|
||||
peekErr <- err
|
||||
}()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case err := <-peekErr:
|
||||
if err != nil {
|
||||
f.stop()
|
||||
return fmt.Errorf("waiting for audio data: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-timer.C:
|
||||
f.stop()
|
||||
<-peekErr // drain after stop unblocks the pipe reader
|
||||
return fmt.Errorf("timed out waiting for audio data (%v)", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegPipeStreamer reads PCM data incrementally from a running ffmpeg process.
|
||||
// Used for live/infinite streams where seeking is not supported.
|
||||
type ffmpegPipeStreamer struct {
|
||||
ffmpegPipe
|
||||
}
|
||||
|
||||
func (f *ffmpegPipeStreamer) Seek(int) error { return nil }
|
||||
|
||||
// decodeFFmpegPipeStream starts ffmpeg reading from src via stdin (pipe:0)
|
||||
// instead of letting ffmpeg open the URL itself. Keeping the caller's reader
|
||||
// chain in the data path means the ICY metadata reader stays attached, so live
|
||||
// radio StreamTitle parsing keeps working for ffmpeg-only codecs (AAC, AAC+,
|
||||
// Opus, ...). src is closed when the stream stops. Used for live/infinite HTTP
|
||||
// streams; seeking is not supported.
|
||||
func decodeFFmpegPipeStream(src io.ReadCloser, sr beep.SampleRate, bitDepth int) (*ffmpegPipeStreamer, beep.Format, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required to play this stream — install it with your package manager")
|
||||
}
|
||||
fp, format, err := startFFmpegPipe("pipe:0", src, sr, bitDepth)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return &ffmpegPipeStreamer{ffmpegPipe: fp}, format, nil
|
||||
}
|
||||
|
||||
// decodeFFmpegLocal starts ffmpeg as a streaming pipe for local files, giving
|
||||
// instant playback start instead of buffering the entire file to memory.
|
||||
// Seeking is supported by killing and restarting ffmpeg with a -ss offset.
|
||||
// Duration is probed via ffprobe so the seek bar works.
|
||||
func decodeFFmpegLocal(path string, sr beep.SampleRate, bitDepth int) (*localFFmpegStreamer, beep.Format, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
ext := filepath.Ext(path)
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required to play %s files — install it with your package manager", ext)
|
||||
}
|
||||
|
||||
_, _, precision := ffmpegPCMArgs(bitDepth)
|
||||
total := probeFrames(path, sr)
|
||||
|
||||
s := &localFFmpegStreamer{ffmpegPipe: ffmpegPipe{total: total, f32: bitDepth == 32}, path: path, sr: sr}
|
||||
if err := s.start(0); err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
|
||||
format := beep.Format{
|
||||
SampleRate: sr,
|
||||
NumChannels: 2,
|
||||
Precision: precision,
|
||||
}
|
||||
return s, format, nil
|
||||
}
|
||||
|
||||
// localFFmpegStreamer streams PCM from a running ffmpeg subprocess for local
|
||||
// files. Unlike pcmStreamer it does not buffer the entire file — playback
|
||||
// starts as soon as ffmpeg begins producing output. Seeking kills the current
|
||||
// process and restarts with -ss (demuxer-level fast seek).
|
||||
type localFFmpegStreamer struct {
|
||||
ffmpegPipe
|
||||
path string
|
||||
sr beep.SampleRate
|
||||
}
|
||||
|
||||
// start launches ffmpeg, optionally seeking to seekPos sample frames.
|
||||
func (s *localFFmpegStreamer) start(seekPos int) error {
|
||||
var args []string
|
||||
if seekPos > 0 {
|
||||
secs := float64(seekPos) / float64(s.sr)
|
||||
args = append(args, "-ss", strconv.FormatFloat(secs, 'f', 3, 64))
|
||||
}
|
||||
pcmFmt, codec, _ := ffmpegPCMArgs(s.bitDepth())
|
||||
args = append(args,
|
||||
"-i", s.path,
|
||||
"-f", pcmFmt,
|
||||
"-acodec", codec,
|
||||
"-ar", strconv.Itoa(int(s.sr)),
|
||||
"-ac", "2",
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
)
|
||||
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("ffmpeg start: %w", err)
|
||||
}
|
||||
|
||||
s.cmd = cmd
|
||||
s.pipe = pipe
|
||||
s.reader = bufio.NewReaderSize(pipe, pipeBufSize)
|
||||
s.pos = seekPos
|
||||
s.err = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *localFFmpegStreamer) Seek(pos int) error {
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
if s.total > 0 && pos > s.total {
|
||||
pos = s.total
|
||||
}
|
||||
s.stop()
|
||||
return s.start(pos)
|
||||
}
|
||||
|
||||
// decodeNavFFmpeg starts ffmpeg with the navBuffer as stdin, returning a
|
||||
// navFFmpegStreamer that begins producing PCM immediately as bytes arrive.
|
||||
// Seeking kills ffmpeg, repositions the navBuffer, and restarts ffmpeg from
|
||||
// the new position — no HTTP reconnect required.
|
||||
func decodeNavFFmpeg(nb *navBuffer, sr beep.SampleRate, bitDepth int, totalFrames int) (*navFFmpegStreamer, beep.Format, error) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required to decode this format — install it with your package manager")
|
||||
}
|
||||
_, _, precision := ffmpegPCMArgs(bitDepth)
|
||||
s := &navFFmpegStreamer{ffmpegPipe: ffmpegPipe{total: totalFrames, f32: bitDepth == 32}, nb: nb, sr: sr}
|
||||
if err := s.start(0); err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
format := beep.Format{
|
||||
SampleRate: sr,
|
||||
NumChannels: 2,
|
||||
Precision: precision,
|
||||
}
|
||||
return s, format, nil
|
||||
}
|
||||
|
||||
// navFFmpegStreamer streams PCM from a running ffmpeg subprocess whose stdin
|
||||
// is a *navBuffer. Playback starts immediately — ffmpeg reads from the buffer
|
||||
// as bytes arrive from the background download. Seeking kills the current
|
||||
// ffmpeg process, repositions the navBuffer to the target byte offset, and
|
||||
// restarts ffmpeg so it reads from that position onwards.
|
||||
type navFFmpegStreamer struct {
|
||||
ffmpegPipe
|
||||
nb *navBuffer
|
||||
sr beep.SampleRate
|
||||
}
|
||||
|
||||
// start launches ffmpeg reading from nb at nb's current position.
|
||||
func (s *navFFmpegStreamer) start(seekPos int) error {
|
||||
pcmFmt, codec, _ := ffmpegPCMArgs(s.bitDepth())
|
||||
cmd := exec.Command("ffmpeg",
|
||||
"-i", "pipe:0",
|
||||
"-f", pcmFmt,
|
||||
"-acodec", codec,
|
||||
"-ar", strconv.Itoa(int(s.sr)),
|
||||
"-ac", "2",
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
)
|
||||
cmd.Stdin = s.nb
|
||||
|
||||
pipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("ffmpeg nav pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("ffmpeg nav start: %w", err)
|
||||
}
|
||||
|
||||
s.cmd = cmd
|
||||
s.pipe = pipe
|
||||
s.reader = bufio.NewReaderSize(pipe, pipeBufSize)
|
||||
s.pos = seekPos
|
||||
s.err = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seek repositions playback to the given sample frame. Kills the current
|
||||
// ffmpeg process, seeks the navBuffer to the proportional byte offset, and
|
||||
// restarts ffmpeg reading from that position.
|
||||
func (s *navFFmpegStreamer) Seek(targetFrame int) error {
|
||||
if targetFrame < 0 {
|
||||
targetFrame = 0
|
||||
}
|
||||
if s.total > 0 && targetFrame > s.total {
|
||||
targetFrame = s.total
|
||||
}
|
||||
|
||||
// Compute the byte offset in the navBuffer proportional to the seek position.
|
||||
byteOffset := int64(0)
|
||||
if s.total > 0 && s.nb.total > 0 {
|
||||
ratio := float64(targetFrame) / float64(s.total)
|
||||
byteOffset = int64(ratio * float64(s.nb.total))
|
||||
}
|
||||
|
||||
s.stop()
|
||||
|
||||
// Reposition the navBuffer to the target byte offset.
|
||||
// navBuffer.Seek blocks if the target hasn't downloaded yet.
|
||||
if _, err := s.nb.Seek(byteOffset, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("nav ffmpeg seek: %w", err)
|
||||
}
|
||||
|
||||
return s.start(targetFrame)
|
||||
}
|
||||
|
||||
// ffmpegPCMArgs returns the ffmpeg format flag, codec name, and beep precision
|
||||
// for the given bit depth. 32-bit uses float PCM (f32le) which preserves
|
||||
// up to 24-bit audio without any truncation; 16-bit uses integer PCM (s16le).
|
||||
func ffmpegPCMArgs(bitDepth int) (format, codec string, precision int) {
|
||||
if bitDepth == 32 {
|
||||
return "f32le", "pcm_f32le", 4
|
||||
}
|
||||
return "s16le", "pcm_s16le", 2
|
||||
}
|
||||
|
||||
// probeFrames uses ffprobe to quickly read file duration from metadata and
|
||||
// converts it to sample frames. This only reads the container header, so it
|
||||
// returns almost instantly even for very large files.
|
||||
func probeFrames(path string, sr beep.SampleRate) int {
|
||||
out, err := exec.Command("ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
path,
|
||||
).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
secs, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int(secs * float64(sr))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// gaplessStreamer is a beep.Streamer that sequences tracks with zero-gap
|
||||
// transitions. It sits at the bottom of the audio pipeline and manages
|
||||
// track sources while the EQ/volume/tap/ctrl chain above it lives forever.
|
||||
//
|
||||
// It always returns (len(samples), true) — it never stops the speaker.
|
||||
// When no audio is available, it fills silence.
|
||||
type gaplessStreamer struct {
|
||||
mu sync.Mutex
|
||||
current beep.Streamer // active track (decoded + resampled)
|
||||
next beep.Streamer // preloaded next track
|
||||
drained atomic.Bool // true when current exhausts with no next
|
||||
onSwap func() // called (in goroutine) on gapless transition
|
||||
}
|
||||
|
||||
// Stream reads samples from the current track. On exhaustion, it seamlessly
|
||||
// fills remaining samples from the next track (like beep.Seq). If no next
|
||||
// track is available, it sets drained=true and fills silence.
|
||||
func (g *gaplessStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
g.mu.Lock()
|
||||
cur := g.current
|
||||
g.mu.Unlock()
|
||||
|
||||
if cur == nil {
|
||||
// No active track — fill silence
|
||||
clear(samples)
|
||||
return len(samples), true
|
||||
}
|
||||
|
||||
n, ok := cur.Stream(samples)
|
||||
|
||||
if !ok || n < len(samples) {
|
||||
// Current track exhausted — try to seamlessly continue with next
|
||||
g.mu.Lock()
|
||||
next := g.next
|
||||
g.next = nil
|
||||
g.current = next
|
||||
swapFn := g.onSwap
|
||||
g.mu.Unlock()
|
||||
|
||||
if next != nil {
|
||||
// Fill remaining buffer from next track — zero gap
|
||||
if n < len(samples) {
|
||||
filled, _ := next.Stream(samples[n:])
|
||||
n += filled
|
||||
}
|
||||
// Notify about the transition (non-blocking)
|
||||
if swapFn != nil {
|
||||
go swapFn()
|
||||
}
|
||||
g.drained.Store(false)
|
||||
} else {
|
||||
// No next track — we've drained
|
||||
g.drained.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fill any remaining with silence
|
||||
for i := n; i < len(samples); i++ {
|
||||
samples[i] = [2]float64{}
|
||||
}
|
||||
return len(samples), true
|
||||
}
|
||||
|
||||
// Err always returns nil — errors are handled per-track at decode time.
|
||||
func (g *gaplessStreamer) Err() error { return nil }
|
||||
|
||||
// SetNext preloads the next track's resampled streamer for gapless transition.
|
||||
func (g *gaplessStreamer) SetNext(s beep.Streamer) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.next = s
|
||||
}
|
||||
|
||||
// Replace interrupts the current track and starts a new one immediately.
|
||||
// Used for manual skip/prev/select operations.
|
||||
func (g *gaplessStreamer) Replace(s beep.Streamer) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.current = s
|
||||
g.next = nil
|
||||
g.drained.Store(false)
|
||||
}
|
||||
|
||||
// Clear removes both current and next tracks. The streamer will output
|
||||
// silence until Replace or SetNext is called.
|
||||
func (g *gaplessStreamer) Clear() {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.current = nil
|
||||
g.next = nil
|
||||
g.drained.Store(false)
|
||||
}
|
||||
|
||||
// Drained reports whether the current track ended with no next track queued.
|
||||
func (g *gaplessStreamer) Drained() bool {
|
||||
return g.drained.Load()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ io.ReadCloser = (*icyReader)(nil)
|
||||
|
||||
// icyReader strips interleaved ICY metadata from a SHOUTcast/Icecast stream.
|
||||
//
|
||||
// The server sends icy-metaint bytes of audio, then a 1-byte length prefix
|
||||
// (multiply by 16 = metadata size), then the metadata block, then repeats.
|
||||
// This reader transparently removes the metadata so decoders only see audio.
|
||||
type icyReader struct {
|
||||
r io.ReadCloser
|
||||
metaInt int // audio bytes between metadata blocks
|
||||
remaining int // audio bytes left before next metadata block
|
||||
onMeta func(string) // called with parsed StreamTitle
|
||||
}
|
||||
|
||||
func newIcyReader(r io.ReadCloser, metaInt int, onMeta func(string)) *icyReader {
|
||||
return &icyReader{
|
||||
r: r,
|
||||
metaInt: metaInt,
|
||||
remaining: metaInt,
|
||||
onMeta: onMeta,
|
||||
}
|
||||
}
|
||||
|
||||
func (ir *icyReader) Read(p []byte) (int, error) {
|
||||
if ir.remaining == 0 {
|
||||
// Read and discard the metadata block.
|
||||
if err := ir.consumeMeta(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ir.remaining = ir.metaInt
|
||||
}
|
||||
|
||||
// Clamp the read so we never cross into a metadata block.
|
||||
p = p[:min(len(p), ir.remaining)]
|
||||
n, err := ir.r.Read(p)
|
||||
ir.remaining -= n
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (ir *icyReader) Close() error {
|
||||
return ir.r.Close()
|
||||
}
|
||||
|
||||
// consumeMeta reads the 1-byte length prefix, then the metadata block,
|
||||
// and parses StreamTitle from it.
|
||||
func (ir *icyReader) consumeMeta() error {
|
||||
// Length prefix: 1 byte, multiply by 16 for actual size.
|
||||
var lenBuf [1]byte
|
||||
if _, err := io.ReadFull(ir.r, lenBuf[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
metaLen := int(lenBuf[0]) * 16
|
||||
if metaLen == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
buf := make([]byte, metaLen)
|
||||
if _, err := io.ReadFull(ir.r, buf); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Metadata is null-padded; trim before parsing.
|
||||
meta := strings.TrimRight(string(buf), "\x00")
|
||||
if title := parseStreamTitle(meta); title != "" && ir.onMeta != nil {
|
||||
ir.onMeta(title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseStreamTitle extracts the StreamTitle value from ICY metadata.
|
||||
// Format: "StreamTitle='Artist - Title';StreamUrl='...';..."
|
||||
func parseStreamTitle(meta string) string {
|
||||
const prefix = "StreamTitle='"
|
||||
_, after, ok := strings.Cut(meta, prefix)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
j := strings.Index(after, "';")
|
||||
if j < 0 {
|
||||
// Tolerate missing semicolon at end of block.
|
||||
j = strings.LastIndex(after, "'")
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return after[:j]
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
func TestParseStreamTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
meta string
|
||||
want string
|
||||
}{
|
||||
{"artist and title", "StreamTitle='Daft Punk - Aerodynamic';StreamUrl='';", "Daft Punk - Aerodynamic"},
|
||||
{"title only", "StreamTitle='Some Show';", "Some Show"},
|
||||
{"empty title", "StreamTitle='';StreamUrl='';", ""},
|
||||
{"no stream title key", "StreamUrl='https://example.com';", ""},
|
||||
{"empty block", "", ""},
|
||||
{"missing trailing semicolon", "StreamTitle='No Semicolon'", "No Semicolon"},
|
||||
{"title containing semicolon", "StreamTitle='A; B - C';StreamUrl='';", "A; B - C"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := parseStreamTitle(tt.meta); got != tt.want {
|
||||
t.Errorf("parseStreamTitle(%q) = %q, want %q", tt.meta, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// icyBlock encodes one metadata block: a 1-byte length prefix (size/16) followed
|
||||
// by the null-padded metadata, matching the SHOUTcast/Icecast wire format.
|
||||
func icyBlock(meta string) []byte {
|
||||
if meta == "" {
|
||||
return []byte{0}
|
||||
}
|
||||
n := (len(meta) + 15) / 16
|
||||
out := make([]byte, 1+n*16)
|
||||
out[0] = byte(n)
|
||||
copy(out[1:], meta)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIcyReaderStripsMetadataAndReportsTitles(t *testing.T) {
|
||||
const metaInt = 8
|
||||
var raw bytes.Buffer
|
||||
raw.WriteString("AAAAAAAA") // audio block 1
|
||||
raw.Write(icyBlock("StreamTitle='Song One';"))
|
||||
raw.WriteString("BBBBBBBB") // audio block 2
|
||||
raw.Write(icyBlock("")) // empty metadata block (no change)
|
||||
raw.WriteString("CCCCCCCC") // audio block 3
|
||||
raw.Write(icyBlock("StreamTitle='Song Two';"))
|
||||
raw.WriteString("DDDDDDDD") // audio block 4 (partial, no trailing meta)
|
||||
|
||||
var titles []string
|
||||
r := newIcyReader(io.NopCloser(&raw), metaInt, func(s string) {
|
||||
titles = append(titles, s)
|
||||
})
|
||||
|
||||
// Read in small chunks to exercise the metaint-boundary clamping.
|
||||
got, err := io.ReadAll(&chunkedReader{r: r, n: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
|
||||
const wantAudio = "AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD"
|
||||
if string(got) != wantAudio {
|
||||
t.Errorf("audio = %q, want %q", got, wantAudio)
|
||||
}
|
||||
|
||||
wantTitles := []string{"Song One", "Song Two"}
|
||||
if len(titles) != len(wantTitles) {
|
||||
t.Fatalf("titles = %v, want %v", titles, wantTitles)
|
||||
}
|
||||
for i, w := range wantTitles {
|
||||
if titles[i] != w {
|
||||
t.Errorf("titles[%d] = %q, want %q", i, titles[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chunkedReader caps each Read at n bytes so tests can drive a reader with many
|
||||
// small reads, exercising boundary handling.
|
||||
type chunkedReader struct {
|
||||
r io.Reader
|
||||
n int
|
||||
}
|
||||
|
||||
func (c *chunkedReader) Read(p []byte) (int, error) {
|
||||
if len(p) > c.n {
|
||||
p = p[:c.n]
|
||||
}
|
||||
return c.r.Read(p)
|
||||
}
|
||||
|
||||
// TestFFmpegPipeStreamCloseUnblocks verifies that closing the stdin-fed ffmpeg
|
||||
// streamer does not hang when its source is a live stream parked in Read. os/exec
|
||||
// copies src -> ffmpeg stdin in a goroutine that cmd.Wait() joins, so Close must
|
||||
// close src first to unblock it. Regression guard for the AAC ICY fix.
|
||||
func TestFFmpegPipeStreamCloseUnblocks(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
|
||||
// io.Pipe with no writer simulates an open-but-idle radio body: Read blocks.
|
||||
pr, pw := io.Pipe()
|
||||
t.Cleanup(func() { pw.Close() })
|
||||
|
||||
dec, _, err := decodeFFmpegPipeStream(pr, beep.SampleRate(44100), 16)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeFFmpegPipeStream: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dec.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Close() hung: stdin-copy goroutine was not unblocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFmpegPipeStreamInitialAudioTimeoutCloses(t *testing.T) {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
t.Skip("ffmpeg not installed")
|
||||
}
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
t.Cleanup(func() { pw.Close() })
|
||||
|
||||
dec, _, err := decodeFFmpegPipeStream(pr, beep.SampleRate(44100), 16)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeFFmpegPipeStream: %v", err)
|
||||
}
|
||||
|
||||
err = dec.waitForInitialAudio(100 * time.Millisecond)
|
||||
if err == nil {
|
||||
t.Fatal("waitForInitialAudio returned nil, want timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out waiting for audio data") {
|
||||
t.Fatalf("waitForInitialAudio error = %v, want timeout", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
dec.Close()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Close() hung after initial audio timeout")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// readStallTimeout is how long Read/Seek wait for new data before returning
|
||||
// an error. The deadline resets whenever the download goroutine delivers new
|
||||
// bytes, so slow-but-progressing downloads are not affected. Only a true
|
||||
// stall (no new data for this duration) triggers the timeout.
|
||||
const readStallTimeout = 5 * time.Second
|
||||
|
||||
// navBuffer is an io.ReadSeekCloser backed by a background HTTP download.
|
||||
// Bytes are appended to data as they arrive. Read and Seek block via cond
|
||||
// if the requested position has not yet been downloaded.
|
||||
//
|
||||
// Lock ordering: navBuffer.mu is a leaf lock. It must never be acquired
|
||||
// while holding speaker.Lock() or player.mu.
|
||||
type navBuffer struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
data []byte // all bytes downloaded so far
|
||||
total int64 // Content-Length from HTTP response (-1 if unknown)
|
||||
pos int64 // current read cursor
|
||||
done bool // true when download goroutine has finished
|
||||
err error // first error from download goroutine
|
||||
cancel context.CancelFunc
|
||||
contentType string // HTTP Content-Type header value
|
||||
bytesIn atomic.Int64 // mirrors len(data); safe for unsynchronised UI reads
|
||||
}
|
||||
|
||||
// newNavBuffer opens an HTTP GET request for rawURL, starts downloading in a
|
||||
// background goroutine, and returns immediately. The caller may begin reading
|
||||
// from the buffer before the download completes.
|
||||
//
|
||||
// Returns (buffer, contentLength, error). contentLength is -1 when the server
|
||||
// does not send a Content-Length header (e.g. chunked transfer encoding).
|
||||
func newNavBuffer(rawURL string) (*navBuffer, int64, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, 0, fmt.Errorf("nav buffer request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "cliamp/1.0 (https://github.com/bjarneo/cliamp)")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, 0, fmt.Errorf("nav buffer connect: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
return nil, 0, fmt.Errorf("nav buffer: http status %s", resp.Status)
|
||||
}
|
||||
|
||||
b := &navBuffer{
|
||||
total: resp.ContentLength, // -1 if unknown
|
||||
cancel: cancel,
|
||||
contentType: resp.Header.Get("Content-Type"),
|
||||
}
|
||||
b.cond = sync.NewCond(&b.mu)
|
||||
|
||||
go b.download(resp.Body)
|
||||
|
||||
return b, resp.ContentLength, nil
|
||||
}
|
||||
|
||||
// download reads the HTTP response body in chunks and appends to data.
|
||||
// Broadcasts on cond after each chunk so blocked Read/Seek calls wake up.
|
||||
func (b *navBuffer) download(body io.ReadCloser) {
|
||||
defer body.Close()
|
||||
chunk := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := body.Read(chunk)
|
||||
if n > 0 {
|
||||
b.mu.Lock()
|
||||
b.data = append(b.data, chunk[:n]...)
|
||||
b.mu.Unlock()
|
||||
b.bytesIn.Add(int64(n))
|
||||
b.cond.Broadcast()
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
b.mu.Lock()
|
||||
b.err = err
|
||||
b.mu.Unlock()
|
||||
b.cond.Broadcast()
|
||||
return
|
||||
}
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.done = true
|
||||
b.mu.Unlock()
|
||||
b.cond.Broadcast()
|
||||
}
|
||||
|
||||
// Read implements io.Reader.
|
||||
// Blocks until at least one byte at pos is available or the download ends.
|
||||
// Returns an error if the download stalls (no new data for readStallTimeout).
|
||||
func (b *navBuffer) Read(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
lastLen := int64(len(b.data))
|
||||
deadline := time.Now().Add(readStallTimeout)
|
||||
for int64(len(b.data)) <= b.pos && !b.done {
|
||||
if b.err != nil {
|
||||
return 0, b.err
|
||||
}
|
||||
// Reset deadline when new data arrives (download is progressing).
|
||||
if curLen := int64(len(b.data)); curLen > lastLen {
|
||||
lastLen = curLen
|
||||
deadline = time.Now().Add(readStallTimeout)
|
||||
} else if time.Now().After(deadline) {
|
||||
return 0, fmt.Errorf("nav buffer: read stalled waiting for data")
|
||||
}
|
||||
// sync.Cond has no timeout support. Schedule a broadcast so this
|
||||
// goroutine wakes periodically to check the deadline.
|
||||
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
|
||||
b.cond.Wait()
|
||||
t.Stop()
|
||||
}
|
||||
if b.err != nil && int64(len(b.data)) <= b.pos {
|
||||
return 0, b.err
|
||||
}
|
||||
if int64(len(b.data)) <= b.pos {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, b.data[b.pos:])
|
||||
b.pos += int64(n)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// Seek implements io.Seeker.
|
||||
// If the target position is beyond what has been downloaded, blocks until
|
||||
// the download reaches it. Returns an error if the download stalls.
|
||||
func (b *navBuffer) Seek(offset int64, whence int) (int64, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
var target int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
target = offset
|
||||
case io.SeekCurrent:
|
||||
target = b.pos + offset
|
||||
case io.SeekEnd:
|
||||
// If Content-Length is known, use it immediately — no need to wait for
|
||||
// the full download. This is the common case with format=raw, and avoids
|
||||
// blocking the FLAC decoder which seeks to SeekEnd during header parsing
|
||||
// just to determine the file size.
|
||||
if b.total >= 0 {
|
||||
target = b.total + offset
|
||||
} else {
|
||||
// Content-Length unknown (chunked): must wait for the full download.
|
||||
lastLen := int64(len(b.data))
|
||||
deadline := time.Now().Add(readStallTimeout)
|
||||
for !b.done {
|
||||
if b.err != nil {
|
||||
return 0, b.err
|
||||
}
|
||||
if curLen := int64(len(b.data)); curLen > lastLen {
|
||||
lastLen = curLen
|
||||
deadline = time.Now().Add(readStallTimeout)
|
||||
} else if time.Now().After(deadline) {
|
||||
return 0, fmt.Errorf("nav buffer: seek stalled waiting for download")
|
||||
}
|
||||
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
|
||||
b.cond.Wait()
|
||||
t.Stop()
|
||||
}
|
||||
target = int64(len(b.data)) + offset
|
||||
}
|
||||
default:
|
||||
return 0, fmt.Errorf("nav buffer: invalid whence %d", whence)
|
||||
}
|
||||
|
||||
if target < 0 {
|
||||
target = 0
|
||||
}
|
||||
|
||||
// Block until the buffer reaches the target position.
|
||||
lastLen := int64(len(b.data))
|
||||
deadline := time.Now().Add(readStallTimeout)
|
||||
for int64(len(b.data)) < target && !b.done {
|
||||
if b.err != nil {
|
||||
return 0, b.err
|
||||
}
|
||||
if curLen := int64(len(b.data)); curLen > lastLen {
|
||||
lastLen = curLen
|
||||
deadline = time.Now().Add(readStallTimeout)
|
||||
} else if time.Now().After(deadline) {
|
||||
return 0, fmt.Errorf("nav buffer: seek stalled waiting for data at offset %d", target)
|
||||
}
|
||||
t := time.AfterFunc(250*time.Millisecond, func() { b.cond.Broadcast() })
|
||||
b.cond.Wait()
|
||||
t.Stop()
|
||||
}
|
||||
|
||||
if target > int64(len(b.data)) {
|
||||
target = int64(len(b.data))
|
||||
}
|
||||
b.pos = target
|
||||
return b.pos, nil
|
||||
}
|
||||
|
||||
// Close cancels the download goroutine and unblocks all waiters.
|
||||
func (b *navBuffer) Close() error {
|
||||
b.cancel()
|
||||
b.mu.Lock()
|
||||
if !b.done {
|
||||
b.done = true
|
||||
b.err = fmt.Errorf("nav buffer: closed")
|
||||
}
|
||||
b.mu.Unlock()
|
||||
b.cond.Broadcast()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ContentType returns the HTTP Content-Type from the server response.
|
||||
func (b *navBuffer) ContentType() string {
|
||||
return b.contentType
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// trackPipeline bundles a decoded track's resources.
|
||||
type trackPipeline struct {
|
||||
decoder beep.StreamSeekCloser // raw decoder (for Position/Duration/Seek)
|
||||
stream beep.Streamer // decoder + optional resample (fed to gapless)
|
||||
format beep.Format
|
||||
seekable bool
|
||||
rc io.ReadCloser // source file/HTTP body
|
||||
knownDuration time.Duration // metadata duration hint (0 = unknown); used when decoder.Len()==0
|
||||
|
||||
// HTTP stream seek-by-reconnect fields.
|
||||
// seekableStream is true when the server returned a Content-Length and we
|
||||
// know the total duration, so we can reconnect with a Range header.
|
||||
seekableStream bool
|
||||
contentLength int64 // Content-Length from the initial HTTP response
|
||||
path string // original URL (needed to reconnect for seek)
|
||||
streamOffset time.Duration // playback time offset of the current ranged connection
|
||||
|
||||
// yt-dlp seek-by-restart: when true, seeking restarts yt-dlp with --download-sections.
|
||||
ytdlSeek bool
|
||||
|
||||
// Network byte counter — incremented by countingReader for HTTP streams.
|
||||
// nil for local files.
|
||||
bytesRead *atomic.Int64
|
||||
}
|
||||
|
||||
// countingReader wraps an io.ReadCloser and atomically counts bytes read.
|
||||
type countingReader struct {
|
||||
inner io.ReadCloser
|
||||
count *atomic.Int64
|
||||
}
|
||||
|
||||
func (cr *countingReader) Read(p []byte) (int, error) {
|
||||
n, err := cr.inner.Read(p)
|
||||
cr.count.Add(int64(n))
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (cr *countingReader) Close() error {
|
||||
return cr.inner.Close()
|
||||
}
|
||||
|
||||
// close releases the pipeline's resources.
|
||||
func (tp *trackPipeline) close() {
|
||||
if tp.decoder != nil {
|
||||
tp.decoder.Close()
|
||||
}
|
||||
if tp.rc != nil {
|
||||
tp.rc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// setKnownDuration stores the metadata duration hint and, for navFFmpegStreamer
|
||||
// pipelines, converts it to sample frames so Len() and proportional seeking work.
|
||||
func (tp *trackPipeline) setKnownDuration(d time.Duration) {
|
||||
tp.knownDuration = d
|
||||
if d > 0 {
|
||||
if ns, ok := tp.decoder.(*navFFmpegStreamer); ok && ns.total == 0 {
|
||||
ns.total = int(ns.sr.N(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closePipelines closes one or more pipelines that are no longer in use.
|
||||
func closePipelines(ps ...*trackPipeline) {
|
||||
for _, tp := range ps {
|
||||
if tp != nil {
|
||||
tp.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// buildPipeline opens and decodes a track, returning a ready-to-play pipeline.
|
||||
// knownDuration is passed in so seek-by-reconnect can be enabled for HTTP streams
|
||||
// that provide a Content-Length. Call buildPipelineAt to open a ranged stream.
|
||||
func (p *Player) buildPipeline(path string) (*trackPipeline, error) {
|
||||
return p.buildPipelineAt(path, 0, 0)
|
||||
}
|
||||
|
||||
func (p *Player) decodeFFmpegURLStream(path string) (*ffmpegPipeStreamer, beep.Format, error) {
|
||||
decoder, format, err := decodeFFmpegStream(path, p.sr, p.bitDepth)
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
if err := decoder.waitForInitialAudio(ffmpegPipeTimeout); err != nil {
|
||||
return nil, beep.Format{}, err
|
||||
}
|
||||
return decoder, format, nil
|
||||
}
|
||||
|
||||
// buildPipelineAt is like buildPipeline but starts the HTTP stream at byteOffset
|
||||
// (using a Range: bytes=N- header) and records timeOffset as the playback origin.
|
||||
// For local files byteOffset is ignored; use decoder.Seek instead.
|
||||
func (p *Player) buildPipelineAt(path string, byteOffset int64, timeOffset time.Duration) (*trackPipeline, error) {
|
||||
// Clear stream title on each new pipeline build.
|
||||
p.streamTitle.Store("")
|
||||
|
||||
// Custom URI schemes (e.g., spotify:track:xxx) are handled by a
|
||||
// registered StreamerFactory, bypassing normal file/HTTP decoding.
|
||||
if factory := p.matchCustomURI(path); factory != nil {
|
||||
decoder, format, dur, err := factory(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("custom streamer: %w", err)
|
||||
}
|
||||
var s beep.Streamer = decoder
|
||||
if format.SampleRate != p.sr {
|
||||
s = beep.Resample(p.resampleQuality, format.SampleRate, p.sr, s)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: s,
|
||||
format: format,
|
||||
seekable: true, // StreamerFactory returns beep.StreamSeekCloser — Seek() is supported
|
||||
knownDuration: dur,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// For HTTP URLs, pass the ICY metadata callback; for local files, nil.
|
||||
var onMeta func(string)
|
||||
if isURL(path) {
|
||||
onMeta = p.setStreamTitle
|
||||
}
|
||||
|
||||
// Buffered HTTP tracks (e.g. Subsonic streams): buffer-while-playing via
|
||||
// navBuffer + ffmpeg pipe. The navBuffer downloads in the background; ffmpeg
|
||||
// reads from it via stdin and starts producing PCM as soon as the first
|
||||
// frames arrive — no waiting for the full download. seekable=true routes
|
||||
// Seek() through navFFmpegStreamer which repositions the navBuffer and
|
||||
// restarts ffmpeg without HTTP reconnect.
|
||||
if isURL(path) && p.isBufferedURL(path) && byteOffset == 0 {
|
||||
nb, contentLen, err := newNavBuffer(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("navidrome buffer: %w", err)
|
||||
}
|
||||
|
||||
// Derive total sample frames from the metadata duration hint so the
|
||||
// seek bar and Len() work correctly. knownDuration is set by the caller
|
||||
// (Play/Preload) onto the returned pipeline after buildPipeline returns,
|
||||
// so we compute it here from timeOffset which is always 0 on first open.
|
||||
// We use p.sr so the frame count matches the output sample rate.
|
||||
totalFrames := 0
|
||||
_ = timeOffset // unused for navBuffer path (byteOffset == 0 guard above)
|
||||
|
||||
decoder, format, err := decodeNavFFmpeg(nb, p.sr, p.bitDepth, totalFrames)
|
||||
if err != nil {
|
||||
nb.Close()
|
||||
return nil, fmt.Errorf("decode navidrome: %w", err)
|
||||
}
|
||||
var s beep.Streamer = decoder
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: s,
|
||||
format: format,
|
||||
seekable: true, // navFFmpegStreamer.Seek() handles seeking without reconnect
|
||||
rc: nb, // trackPipeline.close() calls nb.Close()
|
||||
path: path,
|
||||
bytesRead: &nb.bytesIn,
|
||||
contentLength: contentLen,
|
||||
}, nil
|
||||
}
|
||||
|
||||
ext := formatExt(path)
|
||||
|
||||
// HLS playlists must be opened by ffmpeg directly from the URL so it can
|
||||
// resolve relative chunklist/segment URIs and follow the live segment
|
||||
// window. Feeding the playlist bytes via stdin (the needsFFmpeg path below)
|
||||
// would strip the base URL and break relative segment resolution.
|
||||
if isURL(path) && isHLS(ext) {
|
||||
decoder, format, err := p.decodeFFmpegURLStream(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open hls: %w", err)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder,
|
||||
format: format,
|
||||
path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
src, err := openSourceAt(path, byteOffset, onMeta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open source: %w", err)
|
||||
}
|
||||
rc := src.body
|
||||
|
||||
// Wrap HTTP streams with a counting reader for network stats.
|
||||
var byteCounter *atomic.Int64
|
||||
if isURL(path) {
|
||||
byteCounter = new(atomic.Int64)
|
||||
rc = &countingReader{inner: rc, count: byteCounter}
|
||||
}
|
||||
|
||||
// Determine format: prefer URL extension, fall back to Content-Type.
|
||||
if isURL(path) && ext == ".mp3" && src.contentType != "" {
|
||||
if ctExt := extFromContentType(src.contentType); ctExt != "" {
|
||||
ext = ctExt
|
||||
}
|
||||
}
|
||||
|
||||
// For OGG HTTP streams, use the chained decoder so Icecast radio
|
||||
// continues across song boundaries instead of stopping at EOS.
|
||||
// If Vorbis init fails (e.g. OggFLAC or OggOpus), fall back to ffmpeg.
|
||||
if isURL(path) && ext == ".ogg" {
|
||||
tp, err := p.buildChainedOggPipeline(rc, onMeta)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
decoder, fmt2, err2 := p.decodeFFmpegURLStream(path)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err2)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder,
|
||||
format: fmt2,
|
||||
}, nil
|
||||
}
|
||||
tp.bytesRead = byteCounter
|
||||
tp.contentLength = src.contentLength
|
||||
return tp, nil
|
||||
}
|
||||
|
||||
// For HTTP streams that need ffmpeg (e.g. AAC+), use the streaming
|
||||
// pipe decoder so playback starts immediately instead of buffering
|
||||
// the entire (potentially infinite) stream. Feed ffmpeg from the existing
|
||||
// reader chain via stdin rather than handing it the URL: this keeps the
|
||||
// ICY metadata reader attached so live radio StreamTitle parsing works for
|
||||
// ffmpeg-only codecs (AAC, AAC+, Opus, ...).
|
||||
if isURL(path) && needsFFmpeg(ext) {
|
||||
decoder, format, err := decodeFFmpegPipeStream(rc, p.sr, p.bitDepth)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
if err := decoder.waitForInitialAudio(ffmpegPipeTimeout); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder,
|
||||
format: format,
|
||||
path: path,
|
||||
bytesRead: byteCounter,
|
||||
contentLength: src.contentLength,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SSH streams with ffmpeg-required formats cannot be decoded: ffmpeg
|
||||
// expects a local file path or HTTP URL, not ssh:// pipes.
|
||||
if isSSH(path) && needsFFmpeg(ext) {
|
||||
rc.Close()
|
||||
return nil, fmt.Errorf("SSH streaming does not support %s format (requires ffmpeg)", ext)
|
||||
}
|
||||
|
||||
// For local files that need ffmpeg (e.g. webm, m4a, opus), stream from
|
||||
// a pipe so playback starts instantly instead of buffering the entire
|
||||
// file to memory. Seeking is supported via ffmpeg -ss restart.
|
||||
if !isURL(path) && needsFFmpeg(ext) {
|
||||
rc.Close()
|
||||
decoder, format, err := decodeFFmpegLocal(path, p.sr, p.bitDepth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder, // outputs at target sample rate
|
||||
format: format,
|
||||
seekable: true,
|
||||
path: path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
decoder, format, err := decodeWithExt(rc, ext, path, p.sr, p.bitDepth)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
// If the format already required ffmpeg (e.g., .m4a), decodeWithExt already
|
||||
// tried it — don't invoke ffmpeg a second time.
|
||||
if needsFFmpeg(ext) {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
if isURL(path) {
|
||||
decoder, format, err := p.decodeFFmpegURLStream(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder,
|
||||
format: format,
|
||||
path: path,
|
||||
}, nil
|
||||
}
|
||||
// Native local decoder failed (e.g., IEEE float WAV). Fall back to
|
||||
// buffered ffmpeg decode, which handles more formats.
|
||||
decoder, format, err = decodeFFmpeg(path, p.sr, p.bitDepth)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
// pcmStreamer is fully buffered in memory — always seekable, no rc to manage.
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder, // decodeFFmpeg outputs at target sample rate
|
||||
format: format,
|
||||
seekable: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HTTP streams decoded natively read from a non-seekable http.Response.Body.
|
||||
// FFmpeg-decoded streams are fully buffered in memory and therefore seekable.
|
||||
_, isPCM := decoder.(*pcmStreamer)
|
||||
seekable := !isURL(path) || isPCM
|
||||
|
||||
// Native decoders (mp3, vorbis, flac, wav) wrap rc internally and their
|
||||
// Close() already closes the underlying reader. Set rc to nil so
|
||||
// trackPipeline.close() doesn't double-close the file descriptor.
|
||||
// FFmpeg decoders (reached via needsFFmpeg) read via the path argument;
|
||||
// rc is unused but still needs cleanup, so keep it set for that path.
|
||||
pipelineRC := rc
|
||||
if !isPCM {
|
||||
pipelineRC = nil
|
||||
}
|
||||
|
||||
var s beep.Streamer = decoder
|
||||
if format.SampleRate != p.sr {
|
||||
s = beep.Resample(p.resampleQuality, format.SampleRate, p.sr, s)
|
||||
}
|
||||
|
||||
tp := &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: s,
|
||||
format: format,
|
||||
seekable: seekable,
|
||||
rc: pipelineRC,
|
||||
path: path,
|
||||
streamOffset: timeOffset,
|
||||
bytesRead: byteCounter,
|
||||
}
|
||||
|
||||
// Mark HTTP streams with a known Content-Length as seek-by-reconnect capable.
|
||||
// We need contentLength > 0 to compute byte offsets; knownDuration is checked
|
||||
// later in Seek() when it is set on the pipeline.
|
||||
if isURL(path) && !seekable && src.contentLength > 0 {
|
||||
tp.seekableStream = true
|
||||
tp.contentLength = src.contentLength
|
||||
}
|
||||
|
||||
return tp, nil
|
||||
}
|
||||
|
||||
// buildChainedOggPipeline creates a pipeline with a chainedOggStreamer for
|
||||
// Icecast OGG/Vorbis radio streams that re-initializes the decoder at each
|
||||
// logical bitstream boundary.
|
||||
func (p *Player) buildChainedOggPipeline(rc io.ReadCloser, onMeta func(string)) (*trackPipeline, error) {
|
||||
cs, format, err := newChainedOggStreamer(rc, p.sr, p.resampleQuality, onMeta)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return nil, fmt.Errorf("decode chained ogg: %w", err)
|
||||
}
|
||||
|
||||
return &trackPipeline{
|
||||
decoder: cs,
|
||||
stream: cs, // already resampled internally if needed
|
||||
format: format,
|
||||
seekable: false,
|
||||
rc: nil, // chainedOggStreamer owns the lifecycle
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,891 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
"github.com/gopxl/beep/v2/speaker"
|
||||
)
|
||||
|
||||
// Quality holds configurable audio output parameters.
|
||||
type Quality struct {
|
||||
SampleRate int // output sample rate in Hz (e.g. 44100, 48000)
|
||||
BufferMs int // speaker buffer in milliseconds
|
||||
ResampleQuality int // beep resample quality factor (1–4)
|
||||
BitDepth int // PCM bit depth for FFmpeg output: 16 or 32 (32 = lossless)
|
||||
}
|
||||
|
||||
// StreamerFactory creates a beep.StreamSeekCloser for a custom URI scheme
|
||||
// (e.g., spotify:track:xxx). Returns the streamer, its format, the track
|
||||
// duration, and any error.
|
||||
type StreamerFactory func(uri string) (beep.StreamSeekCloser, beep.Format, time.Duration, error)
|
||||
|
||||
// Player is the audio engine managing the playback pipeline:
|
||||
//
|
||||
// [Gapless] -> [10x Biquad EQ] -> [Volume] -> [Tap] -> [Ctrl] -> speaker
|
||||
// ↑
|
||||
// ├─ current: [Decode A] → [Resample A]
|
||||
// └─ next: [Decode B] → [Resample B] (preloaded)
|
||||
type Player struct {
|
||||
mu sync.Mutex
|
||||
sr beep.SampleRate
|
||||
gapless *gaplessStreamer
|
||||
current *trackPipeline // active track's resources
|
||||
nextPipeline *trackPipeline // preloaded track's resources
|
||||
started bool // true after first speaker.Play()
|
||||
suspendMu sync.Mutex // guards suspended and speaker suspend/resume calls
|
||||
suspended bool // true when speaker.Suspend() has been called
|
||||
ctrl *beep.Ctrl
|
||||
volMin atomic.Uint64 // dB floor stored as Float64bits, range [-90, 0]
|
||||
volume atomic.Uint64 // dB stored as Float64bits, range [volMin, +6]
|
||||
speed atomic.Uint64 // playback speed ratio as Float64bits; 1.0 = normal
|
||||
eqBands [10]atomic.Uint64 // dB stored as math.Float64bits
|
||||
tap *tap
|
||||
playing atomic.Bool
|
||||
paused atomic.Bool
|
||||
mono atomic.Bool
|
||||
resampleQuality int
|
||||
bitDepth int // 16 or 32
|
||||
|
||||
gaplessAdvance atomic.Bool // set when gapless transition fires
|
||||
seekGen atomic.Int64 // generation counter for yt-dlp seeks; incremented to cancel stale seeks
|
||||
|
||||
streamTitle atomic.Value // stores string, set by ICY reader callback
|
||||
customFactories map[string]StreamerFactory // URI scheme prefix -> factory (e.g. "spotify:" -> fn)
|
||||
bufferedURLMatch func(string) bool // optional: returns true for URLs needing navBuffer pipeline
|
||||
|
||||
streamMetaResolver StreamMetadataResolver // optional: API-based now-playing for streams without ICY
|
||||
metaCancel context.CancelFunc // cancels the active metadata poller; guarded by mu
|
||||
}
|
||||
|
||||
// StreamMetadataResolver matches a stream URL to a now-playing fetcher for
|
||||
// broadcasters that carry no inline ICY metadata (e.g. NTS, FIP) and instead
|
||||
// publish the current track via a separate JSON API. It returns a fetch
|
||||
// function, the poll interval, and ok=false when the URL is not recognized.
|
||||
type StreamMetadataResolver func(streamURL string) (fetch func(ctx context.Context) (string, error), interval time.Duration, ok bool)
|
||||
|
||||
// New creates a Player and initializes the speaker with the given quality settings.
|
||||
func New(q Quality) (*Player, error) {
|
||||
if q.SampleRate <= 0 || q.BufferMs <= 0 || q.ResampleQuality <= 0 {
|
||||
return nil, fmt.Errorf("invalid quality settings: SampleRate=%d, BufferMs=%d, ResampleQuality=%d",
|
||||
q.SampleRate, q.BufferMs, q.ResampleQuality)
|
||||
}
|
||||
sr := beep.SampleRate(q.SampleRate)
|
||||
if err := speaker.Init(sr, sr.N(time.Duration(q.BufferMs)*time.Millisecond)); err != nil {
|
||||
return nil, fmt.Errorf("speaker init: %w", err)
|
||||
}
|
||||
bitDepth := q.BitDepth
|
||||
if bitDepth != 32 {
|
||||
bitDepth = 16
|
||||
}
|
||||
p := &Player{sr: sr, resampleQuality: q.ResampleQuality, bitDepth: bitDepth}
|
||||
p.volMin.Store(math.Float64bits(-50))
|
||||
p.speed.Store(math.Float64bits(1.0))
|
||||
p.gapless = &gaplessStreamer{}
|
||||
// Suspend the speaker immediately; the ALSA audio callback goroutine
|
||||
// burns ~2% CPU even on silence. Resume is called on every Play().
|
||||
_ = speaker.Suspend()
|
||||
p.suspended = true
|
||||
p.gapless.onSwap = func() {
|
||||
// Called from audio thread (goroutine) when gapless transition occurs.
|
||||
// Swap current ← nextPipeline and close the old one. The API metadata
|
||||
// poller is intentionally not restarted here: gapless advance is for
|
||||
// finite tracks, while resolver-backed streams (NTS, FIP) are infinite
|
||||
// live radio that is never preloaded as a gapless next track.
|
||||
p.mu.Lock()
|
||||
old := p.current
|
||||
p.current = p.nextPipeline
|
||||
p.nextPipeline = nil
|
||||
p.mu.Unlock()
|
||||
if old != nil {
|
||||
old.close()
|
||||
}
|
||||
p.gaplessAdvance.Store(true)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Play opens and starts playing an audio file. On the first call it builds
|
||||
// the long-lived EQ → volume → tap → ctrl chain and starts the speaker.
|
||||
// Subsequent calls swap only the track source via the gapless streamer.
|
||||
// knownDuration is the metadata duration (use 0 if unknown); it is used as a
|
||||
// fallback when the decoder cannot determine the length (e.g. HTTP streams).
|
||||
func (p *Player) Play(path string, knownDuration time.Duration) error {
|
||||
tp, err := p.buildPipeline(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tp.setKnownDuration(knownDuration)
|
||||
return p.playPipeline(tp)
|
||||
}
|
||||
|
||||
// PlayYTDL starts playing a yt-dlp page URL via a piped yt-dlp | ffmpeg chain.
|
||||
// Playback starts as soon as the first PCM samples arrive (~1-3s). Not seekable.
|
||||
func (p *Player) PlayYTDL(pageURL string, knownDuration time.Duration) error {
|
||||
// Probe duration concurrently with pipeline setup so it doesn't delay playback.
|
||||
probeCh := make(chan time.Duration, 1)
|
||||
if knownDuration == 0 {
|
||||
go func() { probeCh <- probeYTDLDuration(pageURL) }()
|
||||
}
|
||||
tp, err := p.buildYTDLPipeline(pageURL, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if knownDuration == 0 {
|
||||
// The probe ran concurrently with buildYTDLPipeline. Try to
|
||||
// collect the result, but don't block playback for more than 2s.
|
||||
// A hung probeYTDLDuration (e.g. yt-dlp zombie keeping pipes
|
||||
// open) previously blocked here forever, leaving the UI stuck
|
||||
// at "Buffering...".
|
||||
select {
|
||||
case d := <-probeCh:
|
||||
if d > 0 {
|
||||
knownDuration = d
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
// Probe still running — start playback without duration.
|
||||
// The seek bar won't show progress but audio plays immediately.
|
||||
}
|
||||
}
|
||||
tp.knownDuration = knownDuration
|
||||
return p.playPipeline(tp)
|
||||
}
|
||||
|
||||
// playPipeline wires a ready-to-play trackPipeline into the speaker chain.
|
||||
// On the first call it builds the long-lived EQ → volume → tap → ctrl chain.
|
||||
// Subsequent calls swap only the track source via the gapless streamer.
|
||||
func (p *Player) playPipeline(tp *trackPipeline) error {
|
||||
p.resumeSpeaker()
|
||||
|
||||
// Collect old pipelines to close after releasing locks.
|
||||
var oldCurrent, oldNext *trackPipeline
|
||||
|
||||
if p.started {
|
||||
// Lock the speaker so the goroutine finishes any in-progress Stream()
|
||||
// call before we swap the source and unpause. The ctrl.Paused write
|
||||
// must happen under the speaker lock because the audio thread reads it
|
||||
// on every Stream() call.
|
||||
speaker.Lock()
|
||||
p.gapless.Replace(tp.stream)
|
||||
p.ctrl.Paused = false
|
||||
speaker.Unlock()
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
|
||||
oldCurrent = p.current
|
||||
oldNext = p.nextPipeline
|
||||
p.current = tp
|
||||
p.nextPipeline = nil
|
||||
|
||||
firstPlay := !p.started
|
||||
if firstPlay {
|
||||
p.gapless.Replace(tp.stream)
|
||||
|
||||
// Build the long-lived pipeline once
|
||||
var s beep.Streamer = p.gapless
|
||||
s = newSpeedStreamer(s, &p.speed)
|
||||
|
||||
for i := range 10 {
|
||||
s = newBiquad(s, eqFreqs[i], 1.4, &p.eqBands[i], float64(p.sr))
|
||||
}
|
||||
|
||||
p.tap = newTap(s, 4096)
|
||||
s = &volumeStreamer{s: p.tap, vol: &p.volume, mono: &p.mono, cachedDB: math.NaN()}
|
||||
p.ctrl = &beep.Ctrl{Streamer: s}
|
||||
p.started = true
|
||||
}
|
||||
p.playing.Store(true)
|
||||
p.paused.Store(false)
|
||||
p.mu.Unlock()
|
||||
|
||||
if firstPlay {
|
||||
speaker.Play(p.ctrl)
|
||||
}
|
||||
// Start API-based now-playing polling for streams without ICY metadata
|
||||
// (no-op otherwise). Done here, not in buildPipelineAt, so preloaded
|
||||
// pipelines that may never play don't spawn pollers.
|
||||
p.startStreamMetadata(tp.path)
|
||||
// Close old resources asynchronously to avoid blocking the caller
|
||||
// (UI thread) on slow Close() operations (ffmpeg wait, HTTP teardown).
|
||||
go closePipelines(oldCurrent, oldNext)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preload builds a pipeline for the next track and queues it for gapless transition.
|
||||
// knownDuration is the metadata duration (use 0 if unknown).
|
||||
func (p *Player) Preload(path string, knownDuration time.Duration) error {
|
||||
tp, err := p.buildPipeline(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tp.setKnownDuration(knownDuration)
|
||||
return p.preloadPipeline(tp)
|
||||
}
|
||||
|
||||
// PreloadYTDL builds a yt-dlp pipe pipeline and queues it for gapless transition.
|
||||
func (p *Player) PreloadYTDL(pageURL string, knownDuration time.Duration) error {
|
||||
tp, err := p.buildYTDLPipeline(pageURL, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tp.knownDuration = knownDuration
|
||||
return p.preloadPipeline(tp)
|
||||
}
|
||||
|
||||
// preloadPipeline queues a ready trackPipeline for gapless transition.
|
||||
func (p *Player) preloadPipeline(tp *trackPipeline) error {
|
||||
// Lock speaker to atomically swap the gapless next stream, ensuring no
|
||||
// in-flight transition reads from the old pipeline we're about to close.
|
||||
speaker.Lock()
|
||||
p.gapless.SetNext(tp.stream)
|
||||
speaker.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
old := p.nextPipeline
|
||||
p.nextPipeline = tp
|
||||
p.mu.Unlock()
|
||||
|
||||
if old != nil {
|
||||
old.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearPreload discards the preloaded next track (e.g., when shuffle/repeat changes).
|
||||
// Speaker is locked to ensure no in-flight gapless transition can reference the
|
||||
// pipeline we're about to close.
|
||||
func (p *Player) ClearPreload() {
|
||||
speaker.Lock()
|
||||
p.gapless.SetNext(nil)
|
||||
speaker.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
old := p.nextPipeline
|
||||
p.nextPipeline = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
if old != nil {
|
||||
old.close()
|
||||
}
|
||||
}
|
||||
|
||||
// GaplessAdvanced returns true (once) when a gapless transition happened.
|
||||
func (p *Player) GaplessAdvanced() bool {
|
||||
return p.gaplessAdvance.CompareAndSwap(true, false)
|
||||
}
|
||||
|
||||
// TogglePause toggles between paused and playing states.
|
||||
// When pausing, the speaker is suspended to save CPU; when unpausing
|
||||
// it is resumed so the audio callback drains the queued samples.
|
||||
func (p *Player) TogglePause() {
|
||||
speaker.Lock()
|
||||
if p.ctrl != nil {
|
||||
p.ctrl.Paused = !p.ctrl.Paused
|
||||
paused := p.ctrl.Paused
|
||||
speaker.Unlock()
|
||||
p.paused.Store(paused)
|
||||
if paused {
|
||||
p.suspendSpeaker()
|
||||
} else {
|
||||
p.resumeSpeaker()
|
||||
}
|
||||
} else {
|
||||
speaker.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Stop halts playback and releases resources. The speaker is suspended so
|
||||
// the ALSA audio callback goroutine blocks (zero CPU) instead of streaming
|
||||
// silence. Resume is called automatically on the next Play().
|
||||
func (p *Player) Stop() {
|
||||
// Lock speaker to ensure the goroutine finishes any in-progress Stream()
|
||||
// call, then clear the source and pause. After unlock, the speaker will
|
||||
// only see silence from the gapless streamer (paused ctrl).
|
||||
speaker.Lock()
|
||||
p.gapless.Clear()
|
||||
if p.ctrl != nil {
|
||||
p.ctrl.Paused = true
|
||||
}
|
||||
speaker.Unlock()
|
||||
|
||||
// Now safe to close decoder resources — speaker can't be reading them.
|
||||
p.mu.Lock()
|
||||
oldCurrent := p.current
|
||||
oldNext := p.nextPipeline
|
||||
p.current = nil
|
||||
p.nextPipeline = nil
|
||||
p.playing.Store(false)
|
||||
p.paused.Store(false)
|
||||
p.mu.Unlock()
|
||||
|
||||
p.stopStreamMetadata()
|
||||
closePipelines(oldCurrent, oldNext)
|
||||
|
||||
p.suspendSpeaker()
|
||||
}
|
||||
|
||||
// Seek moves the playback position by the given duration (positive or negative).
|
||||
// For seekable local files, the decoder's Seek method is used directly.
|
||||
// For HTTP streams with a known Content-Length and duration (seekableStream),
|
||||
// seek is implemented by reconnecting with a Range: bytes=N- header and
|
||||
// rebuilding the decoder at the computed byte offset. This is known as
|
||||
// seek-by-reconnect.
|
||||
// Returns nil immediately for non-seekable streams (e.g., Icecast radio).
|
||||
// The speaker lock is acquired first (outer), then p.mu briefly to snapshot
|
||||
// the current pipeline, ensuring consistent lock ordering with the audio thread.
|
||||
// Clears the preloaded next pipeline to prevent a stale gapless transition.
|
||||
func (p *Player) Seek(d time.Duration) error {
|
||||
speaker.Lock()
|
||||
defer speaker.Unlock()
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// seekableStream: HTTP stream with Content-Length — reconnect at byte offset.
|
||||
// Release the speaker lock before the slow HTTP reconnect so the audio
|
||||
// thread and UI tick handler aren't blocked during the request.
|
||||
if cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0 {
|
||||
// Compute new absolute position.
|
||||
curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
|
||||
newPos := max(curPos+d, 0)
|
||||
if newPos >= cur.knownDuration {
|
||||
newPos = cur.knownDuration - time.Second
|
||||
}
|
||||
// Map position to byte offset: offset = newPos/duration * contentLength.
|
||||
// Use floating-point to avoid int64 overflow on large files.
|
||||
ratio := float64(newPos) / float64(cur.knownDuration)
|
||||
byteOffset := int64(ratio * float64(cur.contentLength))
|
||||
|
||||
// Snapshot values and mute audio while reconnecting — prevents the
|
||||
// old stream from playing at the pre-seek position during the rebuild.
|
||||
path := cur.path
|
||||
knownDuration := cur.knownDuration
|
||||
contentLength := cur.contentLength
|
||||
p.gapless.Replace(nil)
|
||||
speaker.Unlock()
|
||||
|
||||
// Build a new pipeline starting at the computed byte offset.
|
||||
// Speaker lock is NOT held — HTTP I/O can take seconds on slow networks.
|
||||
tp, err := p.buildPipelineAt(path, byteOffset, newPos)
|
||||
|
||||
speaker.Lock() // re-acquire for defer
|
||||
if err != nil {
|
||||
// Restore the old stream on failure if the pipeline hasn't changed.
|
||||
p.mu.Lock()
|
||||
if p.current == cur {
|
||||
p.gapless.Replace(cur.stream)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
return fmt.Errorf("seek reconnect: %w", err)
|
||||
}
|
||||
tp.knownDuration = knownDuration
|
||||
// seekableStream / contentLength / path are set by buildPipelineAt when
|
||||
// contentLength > 0, but byteOffset shifts the origin, so we keep the
|
||||
// original full-file contentLength and mark seekableStream explicitly.
|
||||
tp.seekableStream = true
|
||||
tp.contentLength = contentLength
|
||||
|
||||
// Verify the current pipeline hasn't changed while we were unlocked
|
||||
// (e.g. track skip or another seek). If it changed, discard our work.
|
||||
p.mu.Lock()
|
||||
if p.current != cur {
|
||||
p.mu.Unlock()
|
||||
go closePipelines(tp)
|
||||
return nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.gapless.Replace(tp.stream)
|
||||
|
||||
// Clear any preloaded next pipeline — its transition point is now stale.
|
||||
p.gapless.SetNext(nil)
|
||||
p.mu.Lock()
|
||||
old := p.current
|
||||
oldNext := p.nextPipeline
|
||||
p.current = tp
|
||||
p.nextPipeline = nil
|
||||
p.mu.Unlock()
|
||||
go closePipelines(old, oldNext)
|
||||
return nil
|
||||
}
|
||||
|
||||
// yt-dlp seek-by-restart: handled outside the speaker lock via SeekYTDL.
|
||||
if cur.ytdlSeek {
|
||||
// Release speaker lock, then do the slow seek.
|
||||
speaker.Unlock()
|
||||
err := p.SeekYTDL(d)
|
||||
speaker.Lock() // re-acquire so defer Unlock works
|
||||
return err
|
||||
}
|
||||
|
||||
// Local file (or ffmpeg-buffered PCM): use the decoder's native Seek.
|
||||
if !cur.seekable {
|
||||
return nil
|
||||
}
|
||||
curSample := cur.decoder.Position()
|
||||
curDur := cur.format.SampleRate.D(curSample)
|
||||
newSample := max(cur.format.SampleRate.N(curDur+d), 0)
|
||||
if newSample >= cur.decoder.Len() {
|
||||
newSample = cur.decoder.Len() - 1
|
||||
}
|
||||
if err := cur.decoder.Seek(newSample); err != nil {
|
||||
return err
|
||||
}
|
||||
// Invalidate the preloaded next pipeline — the gapless transition point
|
||||
// has moved and the old preload may be stale. The speaker lock is already
|
||||
// held, so we can safely clear the gapless next stream.
|
||||
p.gapless.SetNext(nil)
|
||||
p.mu.Lock()
|
||||
old := p.nextPipeline
|
||||
p.nextPipeline = nil
|
||||
p.mu.Unlock()
|
||||
if old != nil {
|
||||
old.close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelSeekYTDL increments the seek generation, causing any in-flight
|
||||
// SeekYTDL to discard its result instead of swapping streams.
|
||||
func (p *Player) CancelSeekYTDL() {
|
||||
p.seekGen.Add(1)
|
||||
}
|
||||
|
||||
// SeekYTDL seeks a yt-dlp stream by restarting the pipeline at the target
|
||||
// position. Must NOT be called with the speaker lock held.
|
||||
// If a newer seek is requested (via CancelSeekYTDL) while this one is
|
||||
// building, the result is discarded.
|
||||
func (p *Player) SeekYTDL(d time.Duration) error {
|
||||
gen := p.seekGen.Load()
|
||||
|
||||
// Snapshot current state without speaker lock.
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil || !cur.ytdlSeek {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read position, then mute the current stream so the speaker outputs
|
||||
// silence while the new pipeline is being built (which blocks on Peek
|
||||
// waiting for yt-dlp data). Without this, the old audio keeps playing
|
||||
// at the pre-seek position during the rebuild.
|
||||
speaker.Lock()
|
||||
curPos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
|
||||
p.gapless.Replace(nil)
|
||||
speaker.Unlock()
|
||||
|
||||
newPos := max(curPos+d, 0)
|
||||
if cur.knownDuration > 0 && newPos >= cur.knownDuration {
|
||||
newPos = cur.knownDuration - time.Second
|
||||
}
|
||||
startSec := int(newPos.Seconds())
|
||||
|
||||
// Build pipeline WITHOUT speaker lock (this is the slow part — spawns yt-dlp).
|
||||
tp, err := p.buildYTDLPipeline(cur.path, startSec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("yt-dlp seek: %w", err)
|
||||
}
|
||||
tp.knownDuration = cur.knownDuration
|
||||
tp.ytdlSeek = true
|
||||
|
||||
// Check if this seek was cancelled while we were building.
|
||||
if p.seekGen.Load() != gen {
|
||||
// A newer seek was requested — discard this result.
|
||||
go closePipelines(tp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now acquire speaker lock to swap streams.
|
||||
speaker.Lock()
|
||||
p.gapless.Replace(tp.stream)
|
||||
p.gapless.SetNext(nil)
|
||||
speaker.Unlock()
|
||||
|
||||
p.mu.Lock()
|
||||
old := p.current
|
||||
oldNext := p.nextPipeline
|
||||
p.current = tp
|
||||
p.nextPipeline = nil
|
||||
p.mu.Unlock()
|
||||
// Clean up old pipelines async to avoid blocking on process wait.
|
||||
go closePipelines(old, oldNext)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsYTDLSeek reports whether the current track uses yt-dlp seek-by-restart.
|
||||
func (p *Player) IsYTDLSeek() bool {
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
return cur != nil && cur.ytdlSeek
|
||||
}
|
||||
|
||||
// IsStreamSeek reports whether the current track uses HTTP seek-by-reconnect.
|
||||
// When true, Seek() releases the speaker lock during the HTTP request, so
|
||||
// callers should dispatch seeks asynchronously to avoid blocking the UI.
|
||||
func (p *Player) IsStreamSeek() bool {
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
return cur != nil && cur.seekableStream && cur.knownDuration > 0 && cur.contentLength > 0
|
||||
}
|
||||
|
||||
// Position returns the current playback position.
|
||||
// For ranged HTTP streams (seek-by-reconnect), streamOffset is added to the
|
||||
// decoder's sample-based position so the reported time is absolute within
|
||||
// the track, not relative to the reconnect point.
|
||||
func (p *Player) Position() time.Duration {
|
||||
speaker.Lock()
|
||||
defer speaker.Unlock()
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return 0
|
||||
}
|
||||
return cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
|
||||
}
|
||||
|
||||
// Duration returns the total duration of the current track.
|
||||
// For seekable local files it is derived from the decoder's sample count.
|
||||
// For HTTP streams where the decoder reports Len()==0, the metadata hint
|
||||
// stored at pipeline build time (knownDuration) is returned instead.
|
||||
func (p *Player) Duration() time.Duration {
|
||||
speaker.Lock()
|
||||
defer speaker.Unlock()
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return 0
|
||||
}
|
||||
if n := cur.decoder.Len(); n > 0 {
|
||||
return cur.format.SampleRate.D(n)
|
||||
}
|
||||
return cur.knownDuration
|
||||
}
|
||||
|
||||
// PositionAndDuration returns both position and duration under a single
|
||||
// speaker lock, avoiding two separate lock acquisitions per tick.
|
||||
func (p *Player) PositionAndDuration() (time.Duration, time.Duration) {
|
||||
speaker.Lock()
|
||||
defer speaker.Unlock()
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return 0, 0
|
||||
}
|
||||
pos := cur.format.SampleRate.D(cur.decoder.Position()) + cur.streamOffset
|
||||
var dur time.Duration
|
||||
if n := cur.decoder.Len(); n > 0 {
|
||||
dur = cur.format.SampleRate.D(n)
|
||||
} else {
|
||||
dur = cur.knownDuration
|
||||
}
|
||||
return pos, dur
|
||||
}
|
||||
|
||||
// SetVolumeMin sets the minimum volume floor in dB, clamped to [-90, 0].
|
||||
// If the current volume is below the new floor it is immediately raised to match.
|
||||
func (p *Player) SetVolumeMin(db float64) {
|
||||
newMin := max(min(db, 0), -90)
|
||||
p.volMin.Store(math.Float64bits(newMin))
|
||||
for {
|
||||
cur := p.volume.Load()
|
||||
curDB := math.Float64frombits(cur)
|
||||
if curDB >= newMin {
|
||||
break
|
||||
}
|
||||
if p.volume.CompareAndSwap(cur, math.Float64bits(newMin)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// VolumeMin returns the current minimum volume floor in dB.
|
||||
func (p *Player) VolumeMin() float64 {
|
||||
return math.Float64frombits(p.volMin.Load())
|
||||
}
|
||||
|
||||
// SetVolume sets the volume in dB, clamped to [VolumeMin, +6].
|
||||
func (p *Player) SetVolume(db float64) {
|
||||
p.volume.Store(math.Float64bits(max(min(db, 6), p.VolumeMin())))
|
||||
}
|
||||
|
||||
// Volume returns the current volume in dB.
|
||||
func (p *Player) Volume() float64 {
|
||||
return math.Float64frombits(p.volume.Load())
|
||||
}
|
||||
|
||||
// SetSpeed sets the playback speed ratio, clamped to [0.25, 2.0].
|
||||
// 1.0 is normal speed, 2.0 is double speed, etc.
|
||||
func (p *Player) SetSpeed(ratio float64) {
|
||||
p.speed.Store(math.Float64bits(max(min(ratio, 2.0), 0.25)))
|
||||
}
|
||||
|
||||
// Speed returns the current playback speed ratio.
|
||||
func (p *Player) Speed() float64 {
|
||||
return math.Float64frombits(p.speed.Load())
|
||||
}
|
||||
|
||||
// ToggleMono switches between stereo and mono (L+R downmix) output.
|
||||
func (p *Player) ToggleMono() {
|
||||
p.mono.Store(!p.mono.Load())
|
||||
}
|
||||
|
||||
// Mono returns true if mono output is enabled.
|
||||
func (p *Player) Mono() bool {
|
||||
return p.mono.Load()
|
||||
}
|
||||
|
||||
// SetEQBand sets a single EQ band's gain in dB, clamped to [-12, +12].
|
||||
func (p *Player) SetEQBand(band int, dB float64) {
|
||||
if band < 0 || band >= 10 {
|
||||
return
|
||||
}
|
||||
p.eqBands[band].Store(math.Float64bits(max(min(dB, 12), -12)))
|
||||
}
|
||||
|
||||
// EQBands returns a copy of all 10 EQ band gains.
|
||||
func (p *Player) EQBands() [10]float64 {
|
||||
var bands [10]float64
|
||||
for i := range 10 {
|
||||
bands[i] = math.Float64frombits(p.eqBands[i].Load())
|
||||
}
|
||||
return bands
|
||||
}
|
||||
|
||||
// IsPlaying returns true if a track is loaded and playing (possibly paused).
|
||||
func (p *Player) IsPlaying() bool {
|
||||
return p.playing.Load()
|
||||
}
|
||||
|
||||
// IsPaused returns true if playback is paused.
|
||||
func (p *Player) IsPaused() bool {
|
||||
return p.paused.Load()
|
||||
}
|
||||
|
||||
// Drained returns true if the current track ended with no preloaded next track.
|
||||
func (p *Player) Drained() bool {
|
||||
return p.gapless.Drained()
|
||||
}
|
||||
|
||||
// HasPreload returns true if a next track is already queued for gapless transition.
|
||||
func (p *Player) HasPreload() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.nextPipeline != nil
|
||||
}
|
||||
|
||||
// StreamTitle returns the current ICY stream title (e.g., "Artist - Song").
|
||||
// Returns "" when no ICY metadata has been received.
|
||||
func (p *Player) StreamTitle() string {
|
||||
v, _ := p.streamTitle.Load().(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// setStreamTitle is the ICY onMeta callback, called from the reader goroutine.
|
||||
func (p *Player) setStreamTitle(title string) {
|
||||
p.streamTitle.Store(title)
|
||||
}
|
||||
|
||||
// RegisterStreamMetadataResolver installs a resolver used to pull now-playing
|
||||
// metadata for streams that lack inline ICY metadata. Pass nil to disable.
|
||||
func (p *Player) RegisterStreamMetadataResolver(r StreamMetadataResolver) {
|
||||
p.mu.Lock()
|
||||
p.streamMetaResolver = r
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// startStreamMetadata (re)starts background now-playing polling for streamURL,
|
||||
// cancelling any previous poller. It is a no-op unless a registered resolver
|
||||
// recognizes the URL, so streams that carry ICY metadata (or local files) are
|
||||
// unaffected. The poller feeds titles through the same path as ICY metadata.
|
||||
func (p *Player) startStreamMetadata(streamURL string) {
|
||||
p.stopStreamMetadata()
|
||||
|
||||
p.mu.Lock()
|
||||
resolver := p.streamMetaResolver
|
||||
p.mu.Unlock()
|
||||
if resolver == nil || streamURL == "" || !isURL(streamURL) {
|
||||
return
|
||||
}
|
||||
fetch, interval, ok := resolver(streamURL)
|
||||
if !ok || fetch == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.mu.Lock()
|
||||
p.metaCancel = cancel
|
||||
p.mu.Unlock()
|
||||
|
||||
go p.pollStreamMetadata(ctx, fetch, interval)
|
||||
}
|
||||
|
||||
// stopStreamMetadata cancels the active metadata poller, if any.
|
||||
func (p *Player) stopStreamMetadata() {
|
||||
p.mu.Lock()
|
||||
cancel := p.metaCancel
|
||||
p.metaCancel = nil
|
||||
p.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// pollStreamMetadata fetches the current title immediately and then on each
|
||||
// interval tick until ctx is cancelled, publishing non-empty titles via
|
||||
// setStreamTitle. A title fetched after cancellation is discarded so a stale
|
||||
// poller cannot clobber the next stream's metadata.
|
||||
func (p *Player) pollStreamMetadata(ctx context.Context, fetch func(context.Context) (string, error), interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
title, err := fetch(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err == nil && title != "" {
|
||||
p.setStreamTitle(title)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seekable reports whether the current track supports seeking.
|
||||
// Returns true for local files (decoder-native seek) and for HTTP streams
|
||||
// with a known Content-Length and duration (seek-by-reconnect).
|
||||
func (p *Player) Seekable() bool {
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return false
|
||||
}
|
||||
return cur.seekable || (cur.seekableStream && cur.knownDuration > 0) || (cur.ytdlSeek && cur.knownDuration > 0)
|
||||
}
|
||||
|
||||
// StreamErr returns the current streamer error, if any (e.g., connection drops).
|
||||
func (p *Player) StreamErr() error {
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return nil
|
||||
}
|
||||
return cur.decoder.Err()
|
||||
}
|
||||
|
||||
// SamplesInto copies the latest audio samples into dst, avoiding allocation.
|
||||
// Returns the number of samples written.
|
||||
func (p *Player) SamplesInto(dst []float64) int {
|
||||
p.mu.Lock()
|
||||
tap := p.tap
|
||||
p.mu.Unlock()
|
||||
if tap == nil {
|
||||
return 0
|
||||
}
|
||||
return tap.SamplesInto(dst)
|
||||
}
|
||||
|
||||
// SampleRate returns the output sample rate in Hz.
|
||||
func (p *Player) SampleRate() int {
|
||||
return int(p.sr)
|
||||
}
|
||||
|
||||
// StreamBytes returns the bytes downloaded and total content length for the
|
||||
// current HTTP stream. Returns (0, 0) for local files or when no counter exists.
|
||||
func (p *Player) StreamBytes() (downloaded, total int64) {
|
||||
p.mu.Lock()
|
||||
cur := p.current
|
||||
p.mu.Unlock()
|
||||
if cur == nil {
|
||||
return 0, 0
|
||||
}
|
||||
if cur.bytesRead != nil {
|
||||
downloaded = cur.bytesRead.Load()
|
||||
}
|
||||
total = cur.contentLength
|
||||
return downloaded, total
|
||||
}
|
||||
|
||||
// RegisterStreamerFactory registers a factory for a custom URI scheme prefix
|
||||
// (e.g., "spotify:"). When buildPipeline encounters a path starting with this
|
||||
// prefix, it calls the factory to create the decoder instead of the normal
|
||||
// file/HTTP pipeline.
|
||||
func (p *Player) RegisterStreamerFactory(scheme string, f StreamerFactory) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.customFactories == nil {
|
||||
p.customFactories = make(map[string]StreamerFactory)
|
||||
}
|
||||
p.customFactories[scheme] = f
|
||||
}
|
||||
|
||||
// RegisterBufferedURLMatcher registers a function that identifies HTTP URLs
|
||||
// requiring the buffered download + ffmpeg pipeline (e.g. Subsonic stream
|
||||
// endpoints). This replaces hardcoded URL pattern checks.
|
||||
func (p *Player) RegisterBufferedURLMatcher(match func(string) bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.bufferedURLMatch = match
|
||||
}
|
||||
|
||||
// suspendSpeaker suspends the ALSA audio callback goroutine so it blocks
|
||||
// on a condition variable instead of busy-looping. Safe to call multiple
|
||||
// times; subsequent calls are no-ops.
|
||||
func (p *Player) suspendSpeaker() {
|
||||
p.suspendMu.Lock()
|
||||
defer p.suspendMu.Unlock()
|
||||
|
||||
if p.suspended {
|
||||
return
|
||||
}
|
||||
if err := speaker.Suspend(); err != nil {
|
||||
// Non-fatal: the ALSA driver may return an error if the context
|
||||
// has already hit a terminal error. Continue without tracking
|
||||
// the suspended state so we don't try to resume a dead context.
|
||||
return
|
||||
}
|
||||
p.suspended = true
|
||||
}
|
||||
|
||||
// resumeSpeaker resumes the ALSA audio callback goroutine. Safe to call
|
||||
// multiple times; subsequent calls are no-ops.
|
||||
func (p *Player) resumeSpeaker() {
|
||||
p.suspendMu.Lock()
|
||||
defer p.suspendMu.Unlock()
|
||||
|
||||
if !p.suspended {
|
||||
return
|
||||
}
|
||||
if err := speaker.Resume(); err != nil {
|
||||
return
|
||||
}
|
||||
p.suspended = false
|
||||
}
|
||||
|
||||
// Close fully stops the speaker and cleans up all resources.
|
||||
func (p *Player) Close() {
|
||||
p.Stop()
|
||||
speaker.Clear()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newTestPlayer returns a Player with only the atomic accessors wired up.
|
||||
// We deliberately skip speaker.Init (which would open a real audio device)
|
||||
// by constructing the struct directly — this limits us to testing the
|
||||
// lock-free getters/setters, not the audio pipeline itself.
|
||||
func newTestPlayer() *Player {
|
||||
p := &Player{}
|
||||
p.volMin.Store(math.Float64bits(-50))
|
||||
p.speed.Store(math.Float64bits(1.0))
|
||||
return p
|
||||
}
|
||||
|
||||
func TestSetVolumeClamps(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{-60, -50}, // below min
|
||||
{-50, -50},
|
||||
{0, 0},
|
||||
{6, 6},
|
||||
{12, 6}, // above max
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p.SetVolume(tt.in)
|
||||
if got := p.Volume(); got != tt.want {
|
||||
t.Errorf("SetVolume(%v) → Volume() = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVolumeMinClamps(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{-100, -90}, // below absolute floor
|
||||
{-90, -90},
|
||||
{-50, -50},
|
||||
{0, 0},
|
||||
{5, 0}, // above max (must be ≤ 0)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p.SetVolumeMin(tt.in)
|
||||
if got := p.VolumeMin(); got != tt.want {
|
||||
t.Errorf("SetVolumeMin(%v) → VolumeMin() = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVolumeMinReconcilesCurrent(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
p.SetVolume(-40)
|
||||
p.SetVolumeMin(-30) // raise floor above current volume
|
||||
if got := p.Volume(); got != -30 {
|
||||
t.Errorf("Volume after SetVolumeMin raise = %v, want -30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeClampedToCustomMin(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
volumeMin float64
|
||||
volume float64
|
||||
want float64
|
||||
}{
|
||||
{"below floor", -30, -40, -30},
|
||||
{"at floor", -30, -30, -30},
|
||||
{"above floor", -30, -10, -10},
|
||||
{"custom floor -60", -60, -70, -60},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
p.SetVolumeMin(tt.volumeMin)
|
||||
p.SetVolume(tt.volume)
|
||||
if got := p.Volume(); got != tt.want {
|
||||
t.Errorf("Volume = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSpeedClamps(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{0.1, 0.25},
|
||||
{0.25, 0.25},
|
||||
{1.0, 1.0},
|
||||
{2.0, 2.0},
|
||||
{3.0, 2.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p.SetSpeed(tt.in)
|
||||
if got := p.Speed(); got != tt.want {
|
||||
t.Errorf("SetSpeed(%v) → Speed() = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleMono(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
|
||||
if p.Mono() {
|
||||
t.Fatal("Mono() should start false")
|
||||
}
|
||||
p.ToggleMono()
|
||||
if !p.Mono() {
|
||||
t.Error("ToggleMono should flip to true")
|
||||
}
|
||||
p.ToggleMono()
|
||||
if p.Mono() {
|
||||
t.Error("ToggleMono should flip back to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEQBandClamps(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
|
||||
tests := []struct {
|
||||
band int
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{0, 20.0, 12.0},
|
||||
{0, -20.0, -12.0},
|
||||
{5, 6.5, 6.5},
|
||||
{9, 0.0, 0.0},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
p.SetEQBand(tt.band, tt.in)
|
||||
bands := p.EQBands()
|
||||
if bands[tt.band] != tt.want {
|
||||
t.Errorf("SetEQBand(%d, %v) → %v, want %v", tt.band, tt.in, bands[tt.band], tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEQBandIgnoresInvalidIndex(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
// Setting an out-of-range band should be a no-op, not panic.
|
||||
p.SetEQBand(-1, 5)
|
||||
p.SetEQBand(10, 5)
|
||||
p.SetEQBand(100, 5)
|
||||
|
||||
for i, b := range p.EQBands() {
|
||||
if b != 0 {
|
||||
t.Errorf("EQBands[%d] = %v, want 0 after invalid writes", i, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEQBandsReturnsCopy(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
p.SetEQBand(0, 6)
|
||||
|
||||
bands := p.EQBands()
|
||||
bands[0] = 999 // mutate local copy
|
||||
|
||||
if p.EQBands()[0] != 6 {
|
||||
t.Error("EQBands() should return a copy — mutation leaked back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlayingDefaultsFalse(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
if p.IsPlaying() {
|
||||
t.Error("IsPlaying() should start false")
|
||||
}
|
||||
if p.IsPaused() {
|
||||
t.Error("IsPaused() should start false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSampleRate(t *testing.T) {
|
||||
p := &Player{sr: 44100}
|
||||
if p.SampleRate() != 44100 {
|
||||
t.Errorf("SampleRate() = %d, want 44100", p.SampleRate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamTitleEmpty(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
if got := p.StreamTitle(); got != "" {
|
||||
t.Errorf("StreamTitle() on fresh player = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetStreamTitle(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
p.setStreamTitle("Artist - Song")
|
||||
if got := p.StreamTitle(); got != "Artist - Song" {
|
||||
t.Errorf("StreamTitle() = %q, want 'Artist - Song'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterStreamerFactory(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
var noop StreamerFactory // nil factory is fine for this storage-only test
|
||||
p.RegisterStreamerFactory("spotify:", noop)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.customFactories) != 1 {
|
||||
t.Errorf("len(customFactories) = %d, want 1", len(p.customFactories))
|
||||
}
|
||||
if _, ok := p.customFactories["spotify:"]; !ok {
|
||||
t.Error("factory not stored under 'spotify:'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterBufferedURLMatcher(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
match := func(u string) bool { return u == "foo" }
|
||||
p.RegisterBufferedURLMatcher(match)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.bufferedURLMatch == nil {
|
||||
t.Error("matcher not stored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentVolumeSetRead(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
var wg sync.WaitGroup
|
||||
const N = 100
|
||||
|
||||
wg.Add(N * 2)
|
||||
for i := 0; i < N; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
p.SetVolume(float64(-i % 30))
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = p.Volume()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// If the race detector didn't flag anything, we're happy.
|
||||
}
|
||||
|
||||
// Ensure the atomic uint64 storage actually wraps dB as expected.
|
||||
func TestVolumeStoragePrecision(t *testing.T) {
|
||||
p := newTestPlayer()
|
||||
p.SetVolume(-3.14)
|
||||
v := math.Float64frombits(p.volume.Load())
|
||||
if math.Abs(v-(-3.14)) > 1e-12 {
|
||||
t.Errorf("stored volume = %v, want -3.14", v)
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// Time-stretching constants tuned for natural speech and music, adapted from
|
||||
// SoundTouch's proven defaults. The long sequence length means crossfade
|
||||
// events are infrequent (~12/sec), and most output is a direct source copy.
|
||||
const (
|
||||
tsSeq = 3584 // sequence: ~81ms @44.1kHz — time between crossfades
|
||||
tsOvlp = 512 // overlap: ~12ms — crossfade region
|
||||
tsWin = tsSeq + tsOvlp // source window per frame (4096)
|
||||
tsSearch = 1024 // search: ±~23ms — covers multiple pitch periods
|
||||
)
|
||||
|
||||
// Pre-computed linear crossfade table: alpha[i] = i / tsOvlp.
|
||||
// Avoids per-sample division in the hot crossfade loop.
|
||||
var tsAlpha [tsOvlp]float64
|
||||
|
||||
func init() {
|
||||
for i := range tsAlpha {
|
||||
tsAlpha[i] = float64(i) / float64(tsOvlp)
|
||||
}
|
||||
}
|
||||
|
||||
// speedStreamer wraps a beep.Streamer and adjusts playback speed without
|
||||
// changing pitch, using WSOLA (Waveform Similarity Overlap-Add) time-stretching.
|
||||
// The speed ratio is stored atomically so the UI thread can change it
|
||||
// while the audio thread reads it.
|
||||
type speedStreamer struct {
|
||||
s beep.Streamer
|
||||
speed *atomic.Uint64 // ratio as Float64bits; 1.0 = normal
|
||||
|
||||
in [][2]float64 // source buffer
|
||||
inN int // valid sample count
|
||||
inPos float64 // fractional analysis cursor
|
||||
|
||||
out [][2]float64 // output ring buffer
|
||||
outRd int
|
||||
outWr int
|
||||
|
||||
tail [tsOvlp][2]float64 // previous frame's trailing samples for crossfade
|
||||
// No tail data exists until the first frame writes to it;
|
||||
// outWr == 0 && outRd == 0 signals this initial state.
|
||||
}
|
||||
|
||||
func newSpeedStreamer(s beep.Streamer, speed *atomic.Uint64) *speedStreamer {
|
||||
return &speedStreamer{
|
||||
s: s,
|
||||
speed: speed,
|
||||
in: make([][2]float64, 16384),
|
||||
out: make([][2]float64, 8192),
|
||||
}
|
||||
}
|
||||
|
||||
// Stream produces output samples. At speed 1.0x it passes through directly.
|
||||
// At other speeds it applies WSOLA time-stretching to preserve pitch.
|
||||
func (ss *speedStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
speed := math.Float64frombits(ss.speed.Load())
|
||||
if speed <= 0 || speed == 1.0 {
|
||||
return ss.passthrough(samples)
|
||||
}
|
||||
|
||||
for ss.outWr-ss.outRd < len(samples) {
|
||||
if !ss.wsolaFrame(speed) {
|
||||
break
|
||||
}
|
||||
}
|
||||
n := ss.drainOut(samples)
|
||||
return n, n > 0
|
||||
}
|
||||
|
||||
func (ss *speedStreamer) passthrough(samples [][2]float64) (int, bool) {
|
||||
d := ss.drainOut(samples)
|
||||
if d == len(samples) {
|
||||
return d, true
|
||||
}
|
||||
// Drain unconsumed source samples before switching to direct reads.
|
||||
srcStart := int(math.Round(ss.inPos))
|
||||
if srcAvail := ss.inN - srcStart; srcAvail > 0 {
|
||||
n := min(len(samples)-d, srcAvail)
|
||||
copy(samples[d:d+n], ss.in[srcStart:srcStart+n])
|
||||
d += n
|
||||
ss.inPos += float64(n)
|
||||
if d == len(samples) {
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
// Reset WSOLA state for clean re-entry.
|
||||
ss.outRd = 0
|
||||
ss.outWr = 0
|
||||
ss.inN = 0
|
||||
ss.inPos = 0
|
||||
n, ok := ss.s.Stream(samples[d:])
|
||||
total := d + n
|
||||
return total, ok || total > 0
|
||||
}
|
||||
|
||||
func (ss *speedStreamer) drainOut(dst [][2]float64) int {
|
||||
avail := ss.outWr - ss.outRd
|
||||
n := min(len(dst), avail)
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
copy(dst[:n], ss.out[ss.outRd:ss.outRd+n])
|
||||
ss.outRd += n
|
||||
if ss.outRd > 8192 {
|
||||
rem := ss.outWr - ss.outRd
|
||||
if rem > 0 {
|
||||
copy(ss.out, ss.out[ss.outRd:ss.outWr])
|
||||
}
|
||||
ss.outRd = 0
|
||||
ss.outWr = rem
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (ss *speedStreamer) fillSource(need int) bool {
|
||||
if drop := int(ss.inPos) - tsSearch; drop > 0 {
|
||||
keep := ss.inN - drop
|
||||
if keep > 0 {
|
||||
copy(ss.in[:keep], ss.in[drop:ss.inN])
|
||||
} else {
|
||||
keep = 0
|
||||
}
|
||||
ss.inN = keep
|
||||
ss.inPos -= float64(drop)
|
||||
}
|
||||
for ss.inN < need {
|
||||
toRead := max(need-ss.inN, 4096)
|
||||
if ss.inN+toRead > cap(ss.in) {
|
||||
newIn := make([][2]float64, ss.inN+toRead)
|
||||
copy(newIn[:ss.inN], ss.in[:ss.inN])
|
||||
ss.in = newIn
|
||||
}
|
||||
n, _ := ss.s.Stream(ss.in[ss.inN : ss.inN+toRead])
|
||||
ss.inN += n
|
||||
if n == 0 {
|
||||
return ss.inN >= need
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// wsolaFrame produces one synthesis frame of tsSeq output samples.
|
||||
//
|
||||
// Frame layout in source:
|
||||
//
|
||||
// [crossfade tsOvlp][direct copy tsSeq-tsOvlp][tail tsOvlp]
|
||||
// |<----------- tsSeq (output) ------------>||<-- saved -->|
|
||||
// |<------------------ tsWin (source read) ---------------->|
|
||||
func (ss *speedStreamer) wsolaFrame(speed float64) bool {
|
||||
expected := int(math.Round(ss.inPos))
|
||||
needed := expected + tsWin + tsSearch + 1
|
||||
if !ss.fillSource(needed) && expected+tsSeq > ss.inN {
|
||||
return false
|
||||
}
|
||||
|
||||
first := ss.outWr == 0 && ss.outRd == 0
|
||||
|
||||
srcOff := expected
|
||||
if !first {
|
||||
srcOff = ss.searchBestOffset(expected)
|
||||
}
|
||||
|
||||
if srcOff+tsWin > ss.inN {
|
||||
srcOff = max(0, ss.inN-tsWin)
|
||||
}
|
||||
if srcOff+tsSeq > ss.inN {
|
||||
return false
|
||||
}
|
||||
|
||||
// Grow output buffer if needed.
|
||||
if ss.outWr+tsSeq > cap(ss.out) {
|
||||
newOut := make([][2]float64, ss.outWr+tsSeq+4096)
|
||||
copy(newOut[:ss.outWr], ss.out[:ss.outWr])
|
||||
ss.out = newOut
|
||||
}
|
||||
|
||||
if first {
|
||||
copy(ss.out[ss.outWr:ss.outWr+tsSeq], ss.in[srcOff:srcOff+tsSeq])
|
||||
} else {
|
||||
// Crossfade the overlap region using pre-computed alpha table.
|
||||
for i := range tsOvlp {
|
||||
a := tsAlpha[i]
|
||||
b := 1 - a
|
||||
ss.out[ss.outWr+i] = [2]float64{
|
||||
b*ss.tail[i][0] + a*ss.in[srcOff+i][0],
|
||||
b*ss.tail[i][1] + a*ss.in[srcOff+i][1],
|
||||
}
|
||||
}
|
||||
// Direct copy the rest — unmodified source samples.
|
||||
copy(ss.out[ss.outWr+tsOvlp:ss.outWr+tsSeq],
|
||||
ss.in[srcOff+tsOvlp:srcOff+tsSeq])
|
||||
}
|
||||
ss.outWr += tsSeq
|
||||
|
||||
// Save tail for next frame's crossfade.
|
||||
copy(ss.tail[:], ss.in[srcOff+tsSeq:srcOff+tsWin])
|
||||
|
||||
ss.inPos += float64(tsSeq) * speed
|
||||
return true
|
||||
}
|
||||
|
||||
// searchBestOffset finds the source position near expected whose start best
|
||||
// matches the previous tail, using normalized cross-correlation.
|
||||
// Normalizing prevents bias toward loud sections. If no good match is found
|
||||
// (all correlations negative or silent), falls back to the expected offset.
|
||||
func (ss *speedStreamer) searchBestOffset(expected int) int {
|
||||
lo := max(0, expected-tsSearch)
|
||||
hi := max(min(ss.inN-tsWin, expected+tsSearch), lo)
|
||||
|
||||
bestOff := min(max(expected, lo), hi)
|
||||
var bestScore float64
|
||||
|
||||
for off := lo; off <= hi; off++ {
|
||||
var corr, norm float64
|
||||
for i := range tsOvlp {
|
||||
corr += ss.tail[i][0]*ss.in[off+i][0] + ss.tail[i][1]*ss.in[off+i][1]
|
||||
norm += ss.in[off+i][0]*ss.in[off+i][0] + ss.in[off+i][1]*ss.in[off+i][1]
|
||||
}
|
||||
if norm < 1e-9 || corr <= 0 {
|
||||
continue
|
||||
}
|
||||
// corr^2/norm avoids sqrt; equivalent ranking to corr/sqrt(norm).
|
||||
score := corr * corr / norm
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
bestOff = off
|
||||
}
|
||||
}
|
||||
return bestOff
|
||||
}
|
||||
|
||||
// Err forwards to the wrapped streamer's error method.
|
||||
func (ss *speedStreamer) Err() error {
|
||||
return ss.s.Err()
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpeedStreamerPassthroughAt1x(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{0.5, -0.5}, count: 1024}
|
||||
var speed atomic.Uint64
|
||||
speed.Store(math.Float64bits(1.0))
|
||||
|
||||
ss := newSpeedStreamer(src, &speed)
|
||||
|
||||
samples := make([][2]float64, 128)
|
||||
n, ok := ss.Stream(samples)
|
||||
|
||||
if n != 128 || !ok {
|
||||
t.Fatalf("Stream() = (%d, %v), want (128, true)", n, ok)
|
||||
}
|
||||
|
||||
for i := range n {
|
||||
if math.Abs(samples[i][0]-0.5) > 1e-9 {
|
||||
t.Errorf("samples[%d][0] = %f, want 0.5", i, samples[i][0])
|
||||
break
|
||||
}
|
||||
if math.Abs(samples[i][1]-(-0.5)) > 1e-9 {
|
||||
t.Errorf("samples[%d][1] = %f, want -0.5", i, samples[i][1])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedStreamerPassthroughAtZero(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{0.3, 0.3}, count: 64}
|
||||
var speed atomic.Uint64
|
||||
speed.Store(math.Float64bits(0.0))
|
||||
|
||||
ss := newSpeedStreamer(src, &speed)
|
||||
|
||||
samples := make([][2]float64, 32)
|
||||
n, ok := ss.Stream(samples)
|
||||
|
||||
if n != 32 || !ok {
|
||||
t.Fatalf("Stream() = (%d, %v), want (32, true)", n, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedStreamer2xProducesOutput(t *testing.T) {
|
||||
src := &sineStreamer{freq: 440, sr: 44100, count: 8192}
|
||||
var speed atomic.Uint64
|
||||
speed.Store(math.Float64bits(2.0))
|
||||
|
||||
ss := newSpeedStreamer(src, &speed)
|
||||
|
||||
samples := make([][2]float64, 4096)
|
||||
n, ok := ss.Stream(samples)
|
||||
|
||||
if n == 0 {
|
||||
t.Fatal("Stream() at 2x speed returned 0 samples")
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("Stream() at 2x speed returned ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedStreamerHalfSpeedProducesOutput(t *testing.T) {
|
||||
src := &sineStreamer{freq: 440, sr: 44100, count: 8192}
|
||||
var speed atomic.Uint64
|
||||
speed.Store(math.Float64bits(0.5))
|
||||
|
||||
ss := newSpeedStreamer(src, &speed)
|
||||
|
||||
samples := make([][2]float64, 4096)
|
||||
n, ok := ss.Stream(samples)
|
||||
|
||||
if n == 0 {
|
||||
t.Fatal("Stream() at 0.5x speed returned 0 samples")
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("Stream() at 0.5x speed returned ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpeedStreamerErr(t *testing.T) {
|
||||
src := &fakeStreamer{}
|
||||
var speed atomic.Uint64
|
||||
|
||||
ss := newSpeedStreamer(src, &speed)
|
||||
if err := ss.Err(); err != nil {
|
||||
t.Errorf("Err() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTsAlphaTable(t *testing.T) {
|
||||
// Verify crossfade alpha table is properly initialized
|
||||
if tsAlpha[0] != 0.0 {
|
||||
t.Errorf("tsAlpha[0] = %f, want 0.0", tsAlpha[0])
|
||||
}
|
||||
lastIdx := len(tsAlpha) - 1
|
||||
expectedLast := float64(lastIdx) / float64(tsOvlp)
|
||||
if math.Abs(tsAlpha[lastIdx]-expectedLast) > 1e-9 {
|
||||
t.Errorf("tsAlpha[%d] = %f, want %f", lastIdx, tsAlpha[lastIdx], expectedLast)
|
||||
}
|
||||
|
||||
// Should be monotonically increasing
|
||||
for i := 1; i < len(tsAlpha); i++ {
|
||||
if tsAlpha[i] <= tsAlpha[i-1] {
|
||||
t.Fatalf("tsAlpha[%d] (%f) <= tsAlpha[%d] (%f)", i, tsAlpha[i], i-1, tsAlpha[i-1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sineStreamer generates a sine wave for testing WSOLA
|
||||
type sineStreamer struct {
|
||||
freq float64
|
||||
sr float64
|
||||
pos int
|
||||
count int
|
||||
}
|
||||
|
||||
func (s *sineStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
n := min(len(samples), s.count-s.pos)
|
||||
if n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
for i := range n {
|
||||
val := math.Sin(2 * math.Pi * s.freq * float64(s.pos+i) / s.sr)
|
||||
samples[i] = [2]float64{val, val}
|
||||
}
|
||||
s.pos += n
|
||||
return n, true
|
||||
}
|
||||
|
||||
func (s *sineStreamer) Err() error { return nil }
|
||||
@@ -0,0 +1,67 @@
|
||||
// Package player provides the audio engine for MP3 playback with
|
||||
// a 10-band parametric EQ, volume control, and sample capture for visualization.
|
||||
package player
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// tap is a streamer wrapper that copies samples into a ring buffer
|
||||
// for real-time FFT visualization. It sits in the audio pipeline
|
||||
// before the volume control so the visualizer sees pre-volume amplitude.
|
||||
//
|
||||
// The write position is updated atomically, allowing the audio thread
|
||||
// (sole writer) and the UI thread (infrequent reader at 50ms intervals)
|
||||
// to operate without mutex contention. Minor sample tearing at the
|
||||
// read boundary is invisible in FFT-based spectrum visualization.
|
||||
type tap struct {
|
||||
s beep.Streamer
|
||||
buf []float64
|
||||
pos atomic.Int64
|
||||
size int
|
||||
}
|
||||
|
||||
// newTap wraps a streamer with a ring buffer of the given size.
|
||||
func newTap(s beep.Streamer, bufSize int) *tap {
|
||||
return &tap{
|
||||
s: s,
|
||||
buf: make([]float64, bufSize),
|
||||
size: bufSize,
|
||||
}
|
||||
}
|
||||
|
||||
// Stream passes audio through while capturing a mono mix into the ring buffer.
|
||||
func (t *tap) Stream(samples [][2]float64) (int, bool) {
|
||||
n, ok := t.s.Stream(samples)
|
||||
p := int(t.pos.Load())
|
||||
for i := range n {
|
||||
t.buf[p] = (samples[i][0] + samples[i][1]) / 2
|
||||
p = (p + 1) % t.size
|
||||
}
|
||||
t.pos.Store(int64(p))
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// Err returns the underlying streamer's error.
|
||||
func (t *tap) Err() error {
|
||||
return t.s.Err()
|
||||
}
|
||||
|
||||
// SamplesInto copies the last len(dst) samples into dst, avoiding allocation.
|
||||
// Uses two copy() calls for the ring buffer wraparound instead of per-element
|
||||
// modulo, which is significantly faster for large buffers (e.g. FFT size 2048).
|
||||
func (t *tap) SamplesInto(dst []float64) int {
|
||||
n := min(len(dst), t.size)
|
||||
p := int(t.pos.Load())
|
||||
start := (p - n + t.size) % t.size
|
||||
if start+n <= t.size {
|
||||
copy(dst, t.buf[start:start+n])
|
||||
} else {
|
||||
first := t.size - start
|
||||
copy(dst[:first], t.buf[start:])
|
||||
copy(dst[first:], t.buf[:n-first])
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// volumeStreamer applies dB gain and optional mono downmix to an audio stream.
|
||||
// Volume and mono are read via atomic operations, eliminating mutex contention
|
||||
// with the UI thread on the audio hot path.
|
||||
type volumeStreamer struct {
|
||||
s beep.Streamer
|
||||
vol *atomic.Uint64 // dB stored as Float64bits
|
||||
mono *atomic.Bool
|
||||
cachedDB float64 // last dB value used to compute cachedGain; starts NaN to force first compute
|
||||
cachedGain float64 // precomputed linear gain = 10^(dB/20)
|
||||
}
|
||||
|
||||
func (v *volumeStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
n, ok := v.s.Stream(samples)
|
||||
if n == 0 {
|
||||
return 0, ok
|
||||
}
|
||||
db := math.Float64frombits(v.vol.Load())
|
||||
mono := v.mono.Load()
|
||||
// Recompute gain only when volume changes (rare) instead of every Stream() call.
|
||||
if db != v.cachedDB {
|
||||
v.cachedGain = math.Pow(10, db/20)
|
||||
v.cachedDB = db
|
||||
}
|
||||
gain := v.cachedGain
|
||||
for i := range n {
|
||||
samples[i][0] *= gain
|
||||
samples[i][1] *= gain
|
||||
if mono {
|
||||
mid := (samples[i][0] + samples[i][1]) / 2
|
||||
samples[i][0] = mid
|
||||
samples[i][1] = mid
|
||||
}
|
||||
}
|
||||
return n, ok
|
||||
}
|
||||
|
||||
func (v *volumeStreamer) Err() error { return v.s.Err() }
|
||||
@@ -0,0 +1,179 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// fakeStreamer produces constant sample values for testing.
|
||||
type fakeStreamer struct {
|
||||
val [2]float64
|
||||
count int
|
||||
}
|
||||
|
||||
func (f *fakeStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
n := min(len(samples), f.count)
|
||||
for i := range n {
|
||||
samples[i] = f.val
|
||||
}
|
||||
f.count -= n
|
||||
return n, n > 0
|
||||
}
|
||||
|
||||
func (f *fakeStreamer) Err() error { return nil }
|
||||
|
||||
func TestVolumeStreamerZeroDB(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{0.5, -0.5}, count: 4}
|
||||
var vol atomic.Uint64
|
||||
vol.Store(math.Float64bits(0.0))
|
||||
var mono atomic.Bool
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
cachedDB: math.NaN(),
|
||||
}
|
||||
|
||||
samples := make([][2]float64, 4)
|
||||
n, ok := vs.Stream(samples)
|
||||
if n != 4 || !ok {
|
||||
t.Fatalf("Stream() = (%d, %v), want (4, true)", n, ok)
|
||||
}
|
||||
|
||||
for i := range n {
|
||||
if math.Abs(samples[i][0]-0.5) > 1e-9 {
|
||||
t.Errorf("samples[%d][0] = %f, want 0.5", i, samples[i][0])
|
||||
}
|
||||
if math.Abs(samples[i][1]-(-0.5)) > 1e-9 {
|
||||
t.Errorf("samples[%d][1] = %f, want -0.5", i, samples[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeStreamerNegativeDB(t *testing.T) {
|
||||
// -20 dB should attenuate by factor of 0.1
|
||||
src := &fakeStreamer{val: [2]float64{1.0, 1.0}, count: 4}
|
||||
var vol atomic.Uint64
|
||||
vol.Store(math.Float64bits(-20.0))
|
||||
var mono atomic.Bool
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
cachedDB: math.NaN(),
|
||||
}
|
||||
|
||||
samples := make([][2]float64, 4)
|
||||
n, _ := vs.Stream(samples)
|
||||
|
||||
expectedGain := math.Pow(10, -20.0/20) // 0.1
|
||||
for i := range n {
|
||||
if math.Abs(samples[i][0]-expectedGain) > 1e-9 {
|
||||
t.Errorf("samples[%d][0] = %f, want %f", i, samples[i][0], expectedGain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeStreamerMono(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{1.0, 0.0}, count: 4}
|
||||
var vol atomic.Uint64
|
||||
vol.Store(math.Float64bits(0.0))
|
||||
var mono atomic.Bool
|
||||
mono.Store(true)
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
cachedDB: math.NaN(),
|
||||
}
|
||||
|
||||
samples := make([][2]float64, 4)
|
||||
n, _ := vs.Stream(samples)
|
||||
|
||||
for i := range n {
|
||||
// Mono should average L and R: (1.0 + 0.0) / 2 = 0.5
|
||||
if math.Abs(samples[i][0]-0.5) > 1e-9 {
|
||||
t.Errorf("samples[%d][0] = %f, want 0.5", i, samples[i][0])
|
||||
}
|
||||
if math.Abs(samples[i][1]-0.5) > 1e-9 {
|
||||
t.Errorf("samples[%d][1] = %f, want 0.5", i, samples[i][1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeStreamerEmptySource(t *testing.T) {
|
||||
src := &fakeStreamer{count: 0}
|
||||
var vol atomic.Uint64
|
||||
vol.Store(math.Float64bits(0.0))
|
||||
var mono atomic.Bool
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
cachedDB: math.NaN(),
|
||||
}
|
||||
|
||||
samples := make([][2]float64, 4)
|
||||
n, ok := vs.Stream(samples)
|
||||
if n != 0 {
|
||||
t.Errorf("n = %d, want 0", n)
|
||||
}
|
||||
if ok {
|
||||
t.Error("ok should be false for empty source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeStreamerGainCaching(t *testing.T) {
|
||||
src := &fakeStreamer{val: [2]float64{1.0, 1.0}, count: 8}
|
||||
var vol atomic.Uint64
|
||||
vol.Store(math.Float64bits(0.0))
|
||||
var mono atomic.Bool
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
cachedDB: math.NaN(),
|
||||
}
|
||||
|
||||
// First call at 0 dB
|
||||
samples := make([][2]float64, 4)
|
||||
vs.Stream(samples)
|
||||
if math.Abs(samples[0][0]-1.0) > 1e-9 {
|
||||
t.Errorf("at 0dB: samples[0][0] = %f, want 1.0", samples[0][0])
|
||||
}
|
||||
|
||||
// Change volume to -6 dB
|
||||
vol.Store(math.Float64bits(-6.0))
|
||||
vs.Stream(samples)
|
||||
expectedGain := math.Pow(10, -6.0/20)
|
||||
if math.Abs(samples[0][0]-expectedGain) > 1e-4 {
|
||||
t.Errorf("at -6dB: samples[0][0] = %f, want ~%f", samples[0][0], expectedGain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeStreamerErr(t *testing.T) {
|
||||
src := &fakeStreamer{}
|
||||
var vol atomic.Uint64
|
||||
var mono atomic.Bool
|
||||
|
||||
vs := &volumeStreamer{
|
||||
s: src,
|
||||
vol: &vol,
|
||||
mono: &mono,
|
||||
}
|
||||
|
||||
if err := vs.Err(); err != nil {
|
||||
t.Errorf("Err() = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure fakeStreamer implements beep.Streamer at compile time.
|
||||
var _ beep.Streamer = (*fakeStreamer)(nil)
|
||||
+428
@@ -0,0 +1,428 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gopxl/beep/v2"
|
||||
)
|
||||
|
||||
// pipeBufSize is the buffer size for audio pipe readers (yt-dlp, ffmpeg).
|
||||
const pipeBufSize = 64 * 1024
|
||||
|
||||
// ytdlPipeTimeout limits how long we wait for yt-dlp to produce initial audio.
|
||||
const ytdlPipeTimeout = 30 * time.Second
|
||||
|
||||
// ytdlCauseGrace bounds how long buildYTDLPipeline waits, after the audio pipe
|
||||
// closes with no data, for yt-dlp or ffmpeg to report why. yt-dlp typically
|
||||
// exits quickly with a stderr message (bot wall, 404, DRM); this only matters
|
||||
// when the process is slow to flush and exit.
|
||||
const ytdlCauseGrace = 3 * time.Second
|
||||
|
||||
// ytdlCookiesFrom is the browser name for --cookies-from-browser (e.g. "chrome").
|
||||
// Set via SetYTDLCookiesFrom at startup.
|
||||
var ytdlCookiesFrom string
|
||||
|
||||
// SetYTDLCookiesFrom configures yt-dlp to use cookies from the given browser
|
||||
// for YouTube Music playback (e.g. "chrome", "firefox", "brave").
|
||||
func SetYTDLCookiesFrom(browser string) {
|
||||
ytdlCookiesFrom = browser
|
||||
}
|
||||
|
||||
// YTDLPAvailable reports whether yt-dlp is installed and on PATH.
|
||||
func YTDLPAvailable() bool {
|
||||
_, err := exec.LookPath("yt-dlp")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// probeYTDLDuration runs a quick yt-dlp --print duration to obtain
|
||||
// the track duration when --flat-playlist didn't provide it.
|
||||
func probeYTDLDuration(pageURL string) time.Duration {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"--skip-download", "--no-playlist", "--socket-timeout", "10", "--print", "duration"}
|
||||
if ytdlCookiesFrom != "" {
|
||||
args = append(args, "--cookies-from-browser", ytdlCookiesFrom)
|
||||
}
|
||||
args = append(args, pageURL)
|
||||
cmd := exec.CommandContext(ctx, "yt-dlp", args...)
|
||||
// WaitDelay ensures cmd.Output() doesn't hang indefinitely if the
|
||||
// process is killed but I/O pipe goroutines haven't drained. Without
|
||||
// this, a zombie yt-dlp child keeping stdout open can block Output()
|
||||
// forever, which in turn blocks PlayYTDL and leaves the UI stuck at
|
||||
// "Buffering...".
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
secs, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
|
||||
if err != nil || secs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(secs * float64(time.Second))
|
||||
}
|
||||
|
||||
// InstallYTDLP attempts to install yt-dlp using the system package manager.
|
||||
// Returns nil on success. The caller should re-check YTDLPAvailable() after.
|
||||
func InstallYTDLP() error {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
if _, err := exec.LookPath("brew"); err == nil {
|
||||
cmd := exec.Command("brew", "install", "yt-dlp")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
// Fall through to pip
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("apt-get"); err == nil {
|
||||
cmd := exec.Command("sudo", "apt-get", "install", "-y", "yt-dlp")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
cmd := exec.Command("sudo", "pacman", "-S", "--noconfirm", "yt-dlp")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
}
|
||||
// Fallback: pip/pipx
|
||||
if path, err := exec.LookPath("pipx"); err == nil {
|
||||
cmd := exec.Command(path, "install", "yt-dlp")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
if path, err := exec.LookPath("pip3"); err == nil {
|
||||
cmd := exec.Command(path, "install", "yt-dlp")
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
return fmt.Errorf("no supported package manager found — install manually: https://github.com/yt-dlp/yt-dlp#installation")
|
||||
}
|
||||
|
||||
// YtdlpInstallHint returns a platform-specific install command suggestion.
|
||||
func YtdlpInstallHint() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "brew install yt-dlp"
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("apt-get"); err == nil {
|
||||
return "sudo apt install yt-dlp"
|
||||
}
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
return "sudo pacman -S yt-dlp"
|
||||
}
|
||||
return "pip install yt-dlp"
|
||||
case "windows":
|
||||
return "winget install yt-dlp"
|
||||
default:
|
||||
return "pip install yt-dlp"
|
||||
}
|
||||
}
|
||||
|
||||
// ffmpegInstallHint returns a platform-specific install command suggestion.
|
||||
func ffmpegInstallHint() string {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
return "brew install ffmpeg"
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("apt-get"); err == nil {
|
||||
return "sudo apt install ffmpeg"
|
||||
}
|
||||
if _, err := exec.LookPath("pacman"); err == nil {
|
||||
return "sudo pacman -S ffmpeg"
|
||||
}
|
||||
return "see https://ffmpeg.org/download.html"
|
||||
case "windows":
|
||||
return "winget install ffmpeg"
|
||||
default:
|
||||
return "see https://ffmpeg.org/download.html"
|
||||
}
|
||||
}
|
||||
|
||||
// ytdlPipeStreamer streams PCM audio from a yt-dlp | ffmpeg pipe chain.
|
||||
// yt-dlp downloads the best audio and writes raw data to stdout; ffmpeg reads
|
||||
// that via a pipe and converts it to PCM on its stdout, which we consume.
|
||||
type ytdlPipeStreamer struct {
|
||||
ytdlCmd *exec.Cmd
|
||||
ffmpegCmd *exec.Cmd
|
||||
pipe io.ReadCloser // ffmpeg stdout (PCM output)
|
||||
reader *bufio.Reader // buffered reader over pipe
|
||||
ytdlErr <-chan error // yt-dlp exit error from monitoring goroutine
|
||||
ffmpegErr <-chan error // ffmpeg exit error from monitoring goroutine
|
||||
buf [pcmFrameSize32]byte
|
||||
f32 bool // true = f32le, false = s16le
|
||||
pos int // samples consumed so far
|
||||
closeOnce sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
func (y *ytdlPipeStreamer) Stream(samples [][2]float64) (int, bool) {
|
||||
n, ok := streamFromReader(y.reader, samples, y.buf[:], y.f32, &y.err)
|
||||
y.pos += n
|
||||
// On EOF with no frames read, surface why the pipe closed (yt-dlp bot
|
||||
// wall, 404, DRM, or undecodable ffmpeg input) instead of a bare EOF.
|
||||
if n == 0 && y.err == nil {
|
||||
if cause := y.waitCause(0); cause != nil {
|
||||
y.err = cause
|
||||
}
|
||||
}
|
||||
return n, ok
|
||||
}
|
||||
|
||||
// waitCause reports why the audio pipe closed without producing audio,
|
||||
// preferring yt-dlp's reason (bot wall, 404, DRM, region block) over ffmpeg's
|
||||
// (undecodable input). With d <= 0 it polls without blocking; otherwise it
|
||||
// waits up to d for a process to report. Returns nil if neither reported an
|
||||
// error, leaving the caller to surface the bare EOF.
|
||||
func (y *ytdlPipeStreamer) waitCause(d time.Duration) error {
|
||||
if d <= 0 {
|
||||
select {
|
||||
case e := <-y.ytdlErr:
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case e := <-y.ffmpegErr:
|
||||
return e
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
deadline := time.After(d)
|
||||
ytdlDone, ffmpegDone := false, false
|
||||
var ffErr error
|
||||
for !ytdlDone || !ffmpegDone {
|
||||
select {
|
||||
case e := <-y.ytdlErr:
|
||||
ytdlDone = true
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
case e := <-y.ffmpegErr:
|
||||
ffmpegDone = true
|
||||
ffErr = e
|
||||
case <-deadline:
|
||||
return ffErr
|
||||
}
|
||||
}
|
||||
return ffErr
|
||||
}
|
||||
|
||||
func (y *ytdlPipeStreamer) Err() error { return y.err }
|
||||
func (y *ytdlPipeStreamer) Len() int { return 0 }
|
||||
func (y *ytdlPipeStreamer) Position() int { return y.pos }
|
||||
func (y *ytdlPipeStreamer) Seek(int) error { return nil }
|
||||
|
||||
func (y *ytdlPipeStreamer) Close() error {
|
||||
y.closeOnce.Do(func() {
|
||||
// Kill both processes to stop downloading/decoding.
|
||||
if y.ytdlCmd.Process != nil {
|
||||
y.ytdlCmd.Process.Kill()
|
||||
}
|
||||
if y.ffmpegCmd.Process != nil {
|
||||
y.ffmpegCmd.Process.Kill()
|
||||
}
|
||||
y.pipe.Close()
|
||||
// The yt-dlp and ffmpeg monitor goroutines own Wait() on their
|
||||
// processes; killing the processes above makes those Waits return.
|
||||
// Both error channels are buffered, so the monitors send and exit
|
||||
// without a receiver — no draining needed here.
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// monitorExit waits for cmd to exit and reports the result on a buffered
|
||||
// channel: a wrapped error preferring captured stderr over the bare exit code
|
||||
// on failure, or nil on clean exit. The channel is buffered so the goroutine
|
||||
// always completes even with no receiver (e.g. after Close kills the process).
|
||||
func monitorExit(cmd *exec.Cmd, stderr *bytes.Buffer, name string) <-chan error {
|
||||
ch := make(chan error, 1)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
switch trimmed := bytes.TrimSpace(stderr.Bytes()); {
|
||||
case err == nil:
|
||||
ch <- nil
|
||||
case len(trimmed) > 0:
|
||||
ch <- fmt.Errorf("%s: %s", name, trimmed)
|
||||
default:
|
||||
ch <- fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
|
||||
// decodeYTDLPipe starts a yt-dlp | ffmpeg pipe chain for the given page URL
|
||||
// and returns a streaming PCM decoder. If startSec > 0, ffmpeg -ss is used
|
||||
// to skip to the desired position in the input stream.
|
||||
func decodeYTDLPipe(pageURL string, sr beep.SampleRate, bitDepth, startSec int) (*ytdlPipeStreamer, beep.Format, error) {
|
||||
if _, err := exec.LookPath("yt-dlp"); err != nil {
|
||||
return nil, beep.Format{}, fmt.Errorf("yt-dlp is required — install: %s", YtdlpInstallHint())
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg is required — install: %s", ffmpegInstallHint())
|
||||
}
|
||||
|
||||
// os.Pipe connects yt-dlp stdout → ffmpeg stdin.
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
return nil, beep.Format{}, fmt.Errorf("os.Pipe: %w", err)
|
||||
}
|
||||
|
||||
// Start yt-dlp: download best audio to stdout.
|
||||
// Prefer direct HTTPS/HTTP streams over HLS (m3u8). HLS requires segment
|
||||
// downloading and muxing which doesn't pipe cleanly to stdout.
|
||||
ytdlArgs := []string{
|
||||
"-f", "bestaudio[protocol=https]/bestaudio[protocol=http]/bestaudio[protocol!=m3u8_native][protocol!=m3u8]/bestaudio",
|
||||
"--no-playlist",
|
||||
"--quiet",
|
||||
"--no-warnings",
|
||||
"--socket-timeout", "15",
|
||||
"-o", "-",
|
||||
}
|
||||
if ytdlCookiesFrom != "" {
|
||||
ytdlArgs = append(ytdlArgs, "--cookies-from-browser", ytdlCookiesFrom)
|
||||
}
|
||||
ytdlArgs = append(ytdlArgs, pageURL)
|
||||
ytdlCmd := exec.Command("yt-dlp", ytdlArgs...)
|
||||
ytdlCmd.Stdout = pw
|
||||
var ytdlStderr bytes.Buffer
|
||||
ytdlCmd.Stderr = &ytdlStderr
|
||||
if err := ytdlCmd.Start(); err != nil {
|
||||
pr.Close()
|
||||
pw.Close()
|
||||
return nil, beep.Format{}, fmt.Errorf("yt-dlp start: %w", err)
|
||||
}
|
||||
|
||||
// Start ffmpeg: read from pipe, output PCM to stdout.
|
||||
// If startSec > 0, use -ss to seek into the input stream.
|
||||
pcmFmt, codec, precision := ffmpegPCMArgs(bitDepth)
|
||||
var ffmpegArgs []string
|
||||
if startSec > 0 {
|
||||
ffmpegArgs = append(ffmpegArgs, "-ss", strconv.Itoa(startSec))
|
||||
}
|
||||
ffmpegArgs = append(ffmpegArgs,
|
||||
"-i", "pipe:0",
|
||||
"-f", pcmFmt,
|
||||
"-acodec", codec,
|
||||
"-ar", strconv.Itoa(int(sr)),
|
||||
"-ac", "2",
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
)
|
||||
ffmpegCmd := exec.Command("ffmpeg", ffmpegArgs...)
|
||||
ffmpegCmd.Stdin = pr
|
||||
var ffmpegStderr bytes.Buffer
|
||||
ffmpegCmd.Stderr = &ffmpegStderr
|
||||
ffmpegPipe, err := ffmpegCmd.StdoutPipe()
|
||||
if err != nil {
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
ytdlCmd.Process.Kill()
|
||||
ytdlCmd.Wait()
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg stdout pipe: %w", err)
|
||||
}
|
||||
if err := ffmpegCmd.Start(); err != nil {
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
ytdlCmd.Process.Kill()
|
||||
ytdlCmd.Wait()
|
||||
return nil, beep.Format{}, fmt.Errorf("ffmpeg start: %w", err)
|
||||
}
|
||||
|
||||
// Close parent's copies of pipe ends. yt-dlp owns pw (write end) and
|
||||
// ffmpeg owns pr (read end). If the parent keeps these open, EOF won't
|
||||
// propagate when the owning process exits.
|
||||
pw.Close()
|
||||
pr.Close()
|
||||
|
||||
// Monitor each process's exit so we can surface why the pipe closed. A
|
||||
// process's stderr is only safe to read after Wait() returns, so the
|
||||
// capture happens inside monitorExit.
|
||||
ytdlErrCh := monitorExit(ytdlCmd, &ytdlStderr, "yt-dlp")
|
||||
ffmpegErrCh := monitorExit(ffmpegCmd, &ffmpegStderr, "ffmpeg")
|
||||
|
||||
format := beep.Format{
|
||||
SampleRate: sr,
|
||||
NumChannels: 2,
|
||||
Precision: precision,
|
||||
}
|
||||
|
||||
return &ytdlPipeStreamer{
|
||||
ytdlCmd: ytdlCmd,
|
||||
ffmpegCmd: ffmpegCmd,
|
||||
pipe: ffmpegPipe,
|
||||
reader: bufio.NewReaderSize(ffmpegPipe, pipeBufSize),
|
||||
ytdlErr: ytdlErrCh,
|
||||
ffmpegErr: ffmpegErrCh,
|
||||
f32: bitDepth == 32,
|
||||
}, format, nil
|
||||
}
|
||||
|
||||
// buildYTDLPipeline creates a trackPipeline for a yt-dlp URL.
|
||||
// If startSec > 0, playback begins at that offset (seek-by-restart).
|
||||
func (p *Player) buildYTDLPipeline(pageURL string, startSec int) (*trackPipeline, error) {
|
||||
p.streamTitle.Store("")
|
||||
|
||||
decoder, format, err := decodeYTDLPipe(pageURL, p.sr, p.bitDepth, startSec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-fill: block until yt-dlp + ffmpeg produce initial audio data.
|
||||
// This runs in a tea.Cmd goroutine (not the UI thread), ensuring the
|
||||
// speaker goroutine won't block on an empty pipe and hold its lock
|
||||
// (which would freeze the UI). A 30s timeout prevents hanging when
|
||||
// yt-dlp is slow to produce output.
|
||||
peekErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := decoder.reader.Peek(1)
|
||||
peekErr <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-peekErr:
|
||||
if err != nil {
|
||||
// The audio pipe closed before producing a byte. Prefer the real
|
||||
// cause from yt-dlp (e.g. "Sign in to confirm you're not a bot",
|
||||
// "HTTP Error 404", DRM, region block) or ffmpeg over the opaque
|
||||
// EOF — the pipe only tells us the upstream closed, not why.
|
||||
cause := decoder.waitCause(ytdlCauseGrace)
|
||||
decoder.Close()
|
||||
if cause != nil {
|
||||
return nil, cause
|
||||
}
|
||||
return nil, fmt.Errorf("waiting for audio data: %w", err)
|
||||
}
|
||||
case <-time.After(ytdlPipeTimeout):
|
||||
decoder.Close()
|
||||
<-peekErr // drain goroutine after Close() unblocks the pipe
|
||||
return nil, fmt.Errorf("timed out waiting for audio data (%v)", ytdlPipeTimeout)
|
||||
}
|
||||
|
||||
return &trackPipeline{
|
||||
decoder: decoder,
|
||||
stream: decoder,
|
||||
format: format,
|
||||
seekable: false,
|
||||
path: pageURL,
|
||||
ytdlSeek: true,
|
||||
streamOffset: time.Duration(startSec) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package player
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWaitCause(t *testing.T) {
|
||||
ytdlErr := errors.New("yt-dlp: Sign in to confirm you're not a bot")
|
||||
ffmpegErr := errors.New("ffmpeg: Invalid data found when processing input")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
d time.Duration
|
||||
ytdl error // value sent on ytdlErr, gated by send
|
||||
send bool // whether to send ytdl at all
|
||||
ff error
|
||||
ffSnd bool
|
||||
want error
|
||||
}{
|
||||
// Blocking (grace) path.
|
||||
{name: "ytdl error preferred over ffmpeg", d: 50 * time.Millisecond, ytdl: ytdlErr, send: true, ff: ffmpegErr, ffSnd: true, want: ytdlErr},
|
||||
{name: "ffmpeg error when ytdl exits clean", d: 50 * time.Millisecond, ytdl: nil, send: true, ff: ffmpegErr, ffSnd: true, want: ffmpegErr},
|
||||
{name: "both clean exit", d: 50 * time.Millisecond, ytdl: nil, send: true, ff: nil, ffSnd: true, want: nil},
|
||||
{name: "ytdl error without ffmpeg report", d: 50 * time.Millisecond, ytdl: ytdlErr, send: true, ff: nil, ffSnd: false, want: ytdlErr},
|
||||
{name: "neither reports before deadline", d: 50 * time.Millisecond, send: false, ffSnd: false, want: nil},
|
||||
// Non-blocking poll (d <= 0).
|
||||
{name: "poll ytdl error", d: 0, ytdl: ytdlErr, send: true, want: ytdlErr},
|
||||
{name: "poll ffmpeg fallback after clean ytdl", d: 0, ytdl: nil, send: true, ff: ffmpegErr, ffSnd: true, want: ffmpegErr},
|
||||
{name: "poll nothing pending", d: 0, send: false, ffSnd: false, want: nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ytdlCh := make(chan error, 1)
|
||||
ffmpegCh := make(chan error, 1)
|
||||
if tt.send {
|
||||
ytdlCh <- tt.ytdl
|
||||
}
|
||||
if tt.ffSnd {
|
||||
ffmpegCh <- tt.ff
|
||||
}
|
||||
y := &ytdlPipeStreamer{ytdlErr: ytdlCh, ffmpegErr: ffmpegCh}
|
||||
got := y.waitCause(tt.d)
|
||||
if !errors.Is(got, tt.want) {
|
||||
t.Fatalf("waitCause = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitCauseReturnsBeforeDeadline verifies that a present yt-dlp error is
|
||||
// returned promptly rather than blocking for the full grace period waiting on
|
||||
// a silent ffmpeg.
|
||||
func TestWaitCauseReturnsBeforeDeadline(t *testing.T) {
|
||||
ytdlCh := make(chan error, 1)
|
||||
ytdlCh <- errors.New("boom")
|
||||
y := &ytdlPipeStreamer{ytdlErr: ytdlCh, ffmpegErr: make(chan error, 1)}
|
||||
|
||||
start := time.Now()
|
||||
if err := y.waitCause(2 * time.Second); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("waitCause blocked %v waiting for ffmpeg; should return on yt-dlp error", elapsed)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user