chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+9
View File
@@ -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
}
+132
View File
@@ -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
}
+175
View File
@@ -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)
}
+20
View File
@@ -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")
}
+61
View File
@@ -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
}
+227
View File
@@ -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
}
+264
View File
@@ -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)
}
}
+39
View File
@@ -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")
}
}
+61
View File
@@ -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)
}
+11
View File
@@ -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
}
+67
View File
@@ -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)
+87
View File
@@ -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() }
+102
View File
@@ -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))
}
}
+522
View File
@@ -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))
}
+106
View File
@@ -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()
}
+95
View File
@@ -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]
}
+166
View File
@@ -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")
}
}
+232
View File
@@ -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
}
+377
View File
@@ -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
}
+891
View File
@@ -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 (14)
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()
}
+265
View File
@@ -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
View File
@@ -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()
}
+136
View File
@@ -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 }
+67
View File
@@ -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
}
+46
View File
@@ -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() }
+179
View File
@@ -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
View File
@@ -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
}
+68
View File
@@ -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)
}
}