diff --git a/go.mod b/go.mod index 9810e47..342444f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,13 @@ module code.m3ta.dev/m3tam3re/talk go 1.26.8 -require github.com/BurntSushi/toml v1.6.0 +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/ebitengine/oto/v3 v3.5.0 +) + +require ( + github.com/ebitengine/purego v0.11.0 // indirect + github.com/jfreymuth/pulse v0.1.3 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index f74b269..bd7c4fb 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,10 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GPXs= +github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o= +github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= +github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14= +github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 0000000..aed92d7 --- /dev/null +++ b/internal/audio/audio.go @@ -0,0 +1,55 @@ +package audio + +import ( + "context" + "fmt" + "io" +) + +// Sink-Namen (Teil des öffentlichen Vertrags; Spec: audio.sink = "oto"|"null"). +const ( + NameOto = "oto" + NameNull = "null" +) + +// Sink gibt Audio-Bytes wieder. Die Bytes stammen aus Provider.Synthesize; +// oto versteht RIFF/WAVE (PCM) — das Lieferformat von osbuiltin. +type Sink interface { + // Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx + // abgebrochen wird). + Play(ctx context.Context, data []byte) error + + // Name liefert den Sink-Namen (audio.sink-Konfiguration). + Name() string +} + +// NewSink liefert den Sink zum konfigurierten Namen ("oto" oder "null"). +// Unbekannte Namen sind ein klarer Fehler. +func NewSink(name string) (Sink, error) { + switch name { + case NameOto: + return OtoSink{}, nil + case NameNull: + return NullSink{}, nil + default: + return nil, fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull) + } +} + +// NullSink verwirft jede Ansage ohne Ton — für CI, Server und Tests. Wurde +// ein W gesetzt, loggt Play eine Zeile darüber (der CLI-Pfad hängt stderr +// an, damit „läuft ohne Ton durch" auch sichtbar bleibt). +type NullSink struct { + W io.Writer // optionaler Log-Output; nil = still verwerfen +} + +// Name implementiert Sink. +func (NullSink) Name() string { return NameNull } + +// Play implementiert Sink: verwirft die Daten. +func (n NullSink) Play(ctx context.Context, data []byte) error { + if n.W != nil { + fmt.Fprintf(n.W, "audio: %d Bytes verworfen (null sink)\n", len(data)) + } + return nil +} diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go new file mode 100644 index 0000000..7a6281f --- /dev/null +++ b/internal/audio/audio_test.go @@ -0,0 +1,177 @@ +package audio + +import ( + "bytes" + "context" + "encoding/binary" + "math" + "os" + "strings" + "testing" +) + +// buildWAV baut nach RIFF-Spezifikation eine WAV-Datei (unabhängig vom +// Parser implementiert, damit Parser und Test-Fixture nicht denselben +// Fehler teilen). +func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte { + var buf bytes.Buffer + buf.WriteString("RIFF") + payload := 28 + len(samples) // fmt (8+16) + data (8+n) + 4 bytes WAVE + binary.Write(&buf, binary.LittleEndian, uint32(payload)) + buf.WriteString("WAVE") + + buf.WriteString("fmt ") + binary.Write(&buf, binary.LittleEndian, uint32(16)) + binary.Write(&buf, binary.LittleEndian, audioFormat) + binary.Write(&buf, binary.LittleEndian, uint16(channels)) + binary.Write(&buf, binary.LittleEndian, uint32(rate)) + blockAlign := channels * bits / 8 + binary.Write(&buf, binary.LittleEndian, uint32(rate*blockAlign)) + binary.Write(&buf, binary.LittleEndian, uint16(blockAlign)) + binary.Write(&buf, binary.LittleEndian, uint16(bits)) + + buf.WriteString("data") + binary.Write(&buf, binary.LittleEndian, uint32(len(samples))) + buf.Write(samples) + return buf.Bytes() +} + +func TestDecodeWAV(t *testing.T) { + t.Run("16-bit mono", func(t *testing.T) { + wav := buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}) + f, pcm, err := decodeWAV(wav) + if err != nil { + t.Fatalf("decodeWAV: %v", err) + } + if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float { + t.Errorf("Format = %+v", f) + } + if string(pcm) != "\x01\x02\x03\x04" { + t.Errorf("PCM = %x", pcm) + } + }) + t.Run("8-bit stereo mit Extra-Chunk", func(t *testing.T) { + wav := buildWAV(1, 2, 8000, 8, []byte{7, 8, 9}) + // LIST-Chunk (word-aligned, ungerade Länge) zwischen fmt und data einfügen + withList := append(wav[:36], append([]byte("LIST\x03\x00\x00\x00abc\x00"), wav[36:]...)...) + f, pcm, err := decodeWAV(withList) + if err != nil { + t.Fatalf("decodeWAV: %v", err) + } + if f.BitsPerCoded != 8 || f.Channels != 2 { + t.Errorf("Format = %+v", f) + } + if string(pcm) != "\x07\x08\x09" { + t.Errorf("PCM = %x", pcm) + } + }) + t.Run("float32", func(t *testing.T) { + wav := buildWAV(3, 1, 44100, 32, []byte{0, 0, 0, 0}) + f, _, err := decodeWAV(wav) + if err != nil { + t.Fatalf("decodeWAV: %v", err) + } + if !f.Float || f.BitsPerCoded != 32 { + t.Errorf("Format = %+v, want Float32", f) + } + }) + t.Run("leerer data-Chunk ist erlaubt", func(t *testing.T) { + wav := buildWAV(1, 1, 22050, 16, nil) + _, pcm, err := decodeWAV(wav) + if err != nil { + t.Fatalf("decodeWAV: %v", err) + } + if len(pcm) != 0 { + t.Errorf("PCM = %x, want leer", pcm) + } + }) +} + +func TestDecodeWAVFehler(t *testing.T) { + cases := []struct { + name string + data []byte + want string + }{ + {"zu kurz", []byte("RIFF"), "zu kurz"}, + {"kein riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"}, + {"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "nicht unterstütztes Format"}, + {"3 kanäle", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "Kanäle"}, + {"ohne data", func() []byte { + wav := buildWAV(1, 1, 22050, 16, []byte{1, 2}) + return wav[:36] // nur fmt-Chunk behalten + }(), "data"}, + {"kaputter chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "überragt"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := decodeWAV(tc.data) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("decodeWAV-Fehler = %v, will Meldung mit %q", err, tc.want) + } + }) + } +} + +func TestNewSink(t *testing.T) { + for _, name := range []string{NameOto, NameNull} { + s, err := NewSink(name) + if err != nil { + t.Fatalf("NewSink(%q): %v", name, err) + } + if s.Name() != name { + t.Errorf("NewSink(%q).Name() = %q", name, s.Name()) + } + } + if _, err := NewSink("mpv"); err == nil { + t.Error("NewSink(\"mpv\") soll fehlschlagen") + } +} + +func TestNullSinkVerwirftUndLoggt(t *testing.T) { + var buf bytes.Buffer + sink := NullSink{W: &buf} + if err := sink.Play(context.Background(), []byte("audio")); err != nil { + t.Fatalf("NullSink.Play: %v", err) + } + if !strings.Contains(buf.String(), "verworfen") { + t.Errorf("NullSink-Log = %q, will Verwurf-Zeile", buf.String()) + } + if sink.Name() != NameNull { + t.Errorf("Name() = %q", sink.Name()) + } +} + +// TestOtoSinkUngueltigeDaten: OtoSink.Play muss an der WAV-Dekodierung +// scheitern, bevor irgend ein Audio-Gerät angefasst wird — das ist der +// CI-/Server-sichere Fehlerpfad des oto-Sinks. +func TestOtoSinkUngueltigeDaten(t *testing.T) { + err := (OtoSink{}).Play(context.Background(), []byte("kein audio")) + if err == nil { + t.Fatal("OtoSink.Play mit Müll: kein Fehler") + } + if !strings.Contains(err.Error(), "wav:") { + t.Errorf("Fehler soll aus dem WAV-Parser kommen: %v", err) + } +} + +// TestOtoSinkEchteWiedergabe läuft nur mit TALK_TEST_REAL_AUDIO=1 (erzeugt +// echten Ton); Standard-Testläufe bleiben stumm. +func TestOtoSinkEchteWiedergabe(t *testing.T) { + if testing.Short() { + t.Skip("Übersprungen (-short)") + } + if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" { + t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_AUDIO=1 ausführen") + } + // 0,1 s 440-Hz-Sinus, 16 Bit mono 22050 Hz. + const rate = 22050 + samples := make([]byte, rate/10*2) + for i := 0; i < rate/10; i++ { + v := int16(8000 * math.Sin(float64(i)/float64(rate)*2*math.Pi*440)) + binary.LittleEndian.PutUint16(samples[i*2:], uint16(v)) + } + if err := (OtoSink{}).Play(context.Background(), buildWAV(1, 1, rate, 16, samples)); err != nil { + t.Fatalf("OtoSink.Play: %v", err) + } +} diff --git a/internal/audio/doc.go b/internal/audio/doc.go index bcfda0f..e52ab7c 100644 --- a/internal/audio/doc.go +++ b/internal/audio/doc.go @@ -1,5 +1,5 @@ // Package audio implementiert die Audio-Ausgabe von talk über das -// Sink-Interface: Playback embedded via oto (PCM/WAV) und ein null-Sink, -// das verwirft (CI, Server, Tests). Auswahl per Config audio.sink. -// Implementierung folgt in Task 02. +// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM) und ein +// null-Sink, das verwirft (CI, Server, Tests). Auswahl per Config +// audio.sink = "oto" | "null" (Default oto). package audio diff --git a/internal/audio/oto.go b/internal/audio/oto.go new file mode 100644 index 0000000..e0248ca --- /dev/null +++ b/internal/audio/oto.go @@ -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() +} diff --git a/internal/audio/wav.go b/internal/audio/wav.go new file mode 100644 index 0000000..c15fc8c --- /dev/null +++ b/internal/audio/wav.go @@ -0,0 +1,81 @@ +package audio + +import ( + "encoding/binary" + "fmt" +) + +// pcmFormat beschreibt rohe PCM-Samples, wie sie oto zur Wiedergabe +// braucht. +type pcmFormat struct { + SampleRate int + Channels int + BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (nur IEEE float) + Float bool +} + +// decodeWAV zerlegt RIFF/WAVE-Bytes in Format und PCM-Daten. +// +// Unterstützt: PCM (AudioFormat 1) mit 8/16 Bit sowie IEEE-Float +// (AudioFormat 3) mit 32 Bit; Mono und Stereo. Alles andere — erst recht +// komprimierte Formate — ist ein klarer Fehler; die OS-TTS-Wege von +// osbuiltin liefern ausschließlich 16-Bit-PCM-WAV. +func decodeWAV(data []byte) (pcmFormat, []byte, error) { + var f pcmFormat + if len(data) < 12 { + return f, nil, fmt.Errorf("wav: zu kurz für RIFF-Header (%d Bytes)", len(data)) + } + if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + return f, nil, fmt.Errorf("wav: kein RIFF/WAVE-Header") + } + + var haveFmt bool + var payload []byte + pos := 12 + for pos+8 <= len(data) { + id := string(data[pos : pos+4]) + size := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) + body := pos + 8 + if body+size > len(data) { + return f, nil, fmt.Errorf("wav: Chunk %q überragt die Datei (Size %d)", id, size) + } + switch id { + case "fmt ": + if size < 16 { + return f, nil, fmt.Errorf("wav: fmt-Chunk zu kurz (%d)", size) + } + audioFormat := binary.LittleEndian.Uint16(data[body : body+2]) + f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4])) + f.SampleRate = int(binary.LittleEndian.Uint32(data[body+4 : body+8])) + f.BitsPerCoded = int(binary.LittleEndian.Uint16(data[body+14 : body+16])) + switch { + case audioFormat == 1 && (f.BitsPerCoded == 8 || f.BitsPerCoded == 16): + case audioFormat == 3 && f.BitsPerCoded == 32: + f.Float = true + default: + return f, nil, fmt.Errorf("wav: nicht unterstütztes Format (AudioFormat %d, %d Bit) — erlaubt: PCM 8/16 Bit, Float 32 Bit", + audioFormat, f.BitsPerCoded) + } + if f.Channels < 1 || f.Channels > 2 { + return f, nil, fmt.Errorf("wav: %d Kanäle (erlaubt: 1, 2)", f.Channels) + } + if f.SampleRate <= 0 { + return f, nil, fmt.Errorf("wav: SampleRate %d", f.SampleRate) + } + haveFmt = true + case "data": + payload = data[body : body+size] + } + pos = body + size + if size%2 == 1 { // Chunks sind word-aligned + pos++ + } + } + if !haveFmt { + return f, nil, fmt.Errorf("wav: kein fmt-Chunk") + } + if payload == nil { + return f, nil, fmt.Errorf("wav: kein data-Chunk") + } + return f, payload, nil +}