228 lines
6.0 KiB
Go
228 lines
6.0 KiB
Go
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
|
|
}
|