oto-Sink bekommt eine Format-Weiche: RIFF/WAVE-PCM (osbuiltin) und MP3 (ElevenLabs/OpenAI/Edge ab Task 03). Dekoder ist github.com/hajimehoshi/ go-mp3 v0.3.4 — reines Go ohne CGO, verifiziert per GOOS/GOARCH-Matrix mit CGO_ENABLED=0 (alle 6 Targets bauen). Der Dekoder liefert immer 16-Bit-LE stereo (Mono-Quellen wie Edge 24 kHz werden gespiegelt); die Sample-Rate kommt aus dem ersten Frame. Fixtures sind echte, einmalig erzeugte MP3s (mono 24 kHz 48 kbit/s = Edge-Format, stereo 44,1 kHz = Cloud-typisch), als Base64 eingebettet — Tests bleiben werkzeugfrei.
107 lines
2.7 KiB
Go
107 lines
2.7 KiB
Go
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ebitengine/oto/v3"
|
|
)
|
|
|
|
// OtoSink spielt Audio embedded über oto (github.com/ebitengine/oto/v3,
|
|
// gepinnt auf v3.5.0) ab — kein externer Player nötig. oto lädt die
|
|
// Audiotreiber zur Laufzeit ohne CGO (purego), dadurch bleibt talk
|
|
// cross-compilierbar.
|
|
type OtoSink struct{}
|
|
|
|
// Name implementiert Sink.
|
|
func (OtoSink) Name() string { return NameOto }
|
|
|
|
// contextCache hält je PCM-Format einen oto-Kontext vor: oto unterstützt
|
|
// nur EINEN Kontext pro Prozess ("Creating multiple contexts is NOT
|
|
// supported"), und ein Kontext ist an genau ein Format gebunden. Die CLI
|
|
// spielt pro Prozess einmal (Task 02); der Daemon (Task 04) profitiert vom
|
|
// Cache.
|
|
var contextCache = struct {
|
|
sync.Mutex
|
|
byFormat map[pcmFormat]*otoContext
|
|
}{byFormat: map[pcmFormat]*otoContext{}}
|
|
|
|
type otoContext struct {
|
|
ctx *oto.Context
|
|
ready chan struct{}
|
|
}
|
|
|
|
// otoFormat mappt ein PCM-Format auf das oto-Format.
|
|
func (f pcmFormat) otoFormat() oto.Format {
|
|
switch {
|
|
case f.Float:
|
|
return oto.FormatFloat32LE
|
|
case f.BitsPerCoded == 8:
|
|
return oto.FormatUnsignedInt8
|
|
default:
|
|
return oto.FormatSignedInt16LE
|
|
}
|
|
}
|
|
|
|
// contextFor liefert den (gecachten) oto-Kontext für ein Format.
|
|
func contextFor(f pcmFormat) (*otoContext, error) {
|
|
contextCache.Lock()
|
|
defer contextCache.Unlock()
|
|
if c, ok := contextCache.byFormat[f]; ok {
|
|
return c, nil
|
|
}
|
|
op := &oto.NewContextOptions{
|
|
SampleRate: f.SampleRate,
|
|
ChannelCount: f.Channels,
|
|
Format: f.otoFormat(),
|
|
}
|
|
ctx, ready, err := oto.NewContext(op)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oto: Kontext (Audio-Gerät?) fehlgeschlagen: %w", err)
|
|
}
|
|
c := &otoContext{ctx: ctx, ready: ready}
|
|
contextCache.byFormat[f] = c
|
|
return c, nil
|
|
}
|
|
|
|
// Play implementiert Sink: dekodiert die Daten (RIFF/WAVE-PCM von osbuiltin
|
|
// oder MP3 von den Cloud-Providern, siehe mp3.go) und spielt die Samples
|
|
// blockierend ab. Fehler des Audio-Geräts oder des Formats werden klar
|
|
// gemeldet.
|
|
func (OtoSink) Play(ctx context.Context, data []byte) error {
|
|
f, pcm, err := decodeAudio(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := contextFor(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-c.ready:
|
|
if err := c.ctx.Err(); err != nil {
|
|
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
player := c.ctx.NewPlayer(bytes.NewReader(pcm))
|
|
defer player.Close()
|
|
player.Play()
|
|
for player.IsPlaying() {
|
|
if err := ctx.Err(); err != nil {
|
|
player.Close()
|
|
return err
|
|
}
|
|
if err := player.Err(); err != nil {
|
|
return fmt.Errorf("oto: Wiedergabe fehlgeschlagen: %w", err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
return player.Err()
|
|
}
|