feat(audio): Audio-Sinks oto und null mit eigener WAV-Dekodierung
Sink-Vertrag Play/Name; oto-Sink (github.com/ebitengine/oto/v3, auf v3.5.0 gepinnt — aktuelle stabile Major-Version; purego statt CGO, damit cross-compilierbar) spielt RIFF/WAVE-PCM blockierend, ein Context pro PCM-Format gecacht, da oto nur einen Kontext pro Prozess erlaubt. null-Sink verwirft (CI/Server/Tests) und loggt optional. WAV-Parser (RIFF-Chunks, PCM 8/16 Bit, Float32) mit klarer Fehler für alles andere. Echte Wiedergabe nur hinter TALK_TEST_REAL_AUDIO=1.
This commit is contained in:
1 parent
5f450ea015
commit
523e6f4bf2
7 files changed
+439
-4
No files matched your search
@@ -0,0 +1,105 @@
|
||||
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 WAV (RIFF/PCM) 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 := decodeWAV(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()
|
||||
}
|
||||
Reference in new issue
Block a user