refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4d30bd95ad
commit
85fa64cfe6
95 files changed
+4707
-4854
No files matched your search
+19
-19
@@ -6,28 +6,28 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// Sink-Namen (Teil des öffentlichen Vertrags; Spec: audio.sink = "oto"|"null").
|
||||
// Sink names (part of the public contract; 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) und MP3
|
||||
// (das Lieferformat der Cloud-Provider ElevenLabs/OpenAI/Edge).
|
||||
// Sink plays audio bytes. The bytes come from Provider.Synthesize; oto
|
||||
// understands RIFF/WAVE (PCM, the delivery format of osbuiltin) and MP3
|
||||
// (the delivery format of the cloud providers ElevenLabs/OpenAI/Edge).
|
||||
type Sink interface {
|
||||
// Play spielt die Daten blockierend ab (bis zum Ende oder bis ctx
|
||||
// abgebrochen wird).
|
||||
// Play plays the data and blocks until the end or until ctx is
|
||||
// cancelled.
|
||||
Play(ctx context.Context, data []byte) error
|
||||
|
||||
// Name liefert den Sink-Namen (audio.sink-Konfiguration).
|
||||
// Name returns the sink name (audio.sink configuration).
|
||||
Name() string
|
||||
}
|
||||
|
||||
// NewSink liefert den Sink zum konfigurierten Namen ("oto" oder "null").
|
||||
// log ist ein optionaler Log-Output — die CLI übergibt ihr stderr, über den
|
||||
// der null-Sink seine Verwurf-Zeile schreibt (nil = still verwerfen); der
|
||||
// oto-Sink braucht kein Log. Unbekannte Namen sind ein klarer Fehler.
|
||||
// NewSink returns the sink for the configured name ("oto" or "null"). log
|
||||
// is an optional log output: the CLI passes its stderr so the null sink can
|
||||
// write its discard line (nil = discard silently); the oto sink needs no
|
||||
// log. Unknown names are a clear error.
|
||||
func NewSink(name string, log io.Writer) (Sink, error) {
|
||||
switch name {
|
||||
case NameOto:
|
||||
@@ -35,24 +35,24 @@ func NewSink(name string, log io.Writer) (Sink, error) {
|
||||
case NameNull:
|
||||
return NullSink{W: log}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
|
||||
return nil, fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
|
||||
}
|
||||
}
|
||||
|
||||
// NullSink verwirft jede Ansage ohne Ton — für CI, Server und Tests. Wurde
|
||||
// ein W gesetzt (NewSink reicht dafür den Log-Output der CLI durch), loggt
|
||||
// Play eine Zeile darüber, damit „läuft ohne Ton durch" sichtbar bleibt.
|
||||
// NullSink discards every announcement without sound; for CI, servers and
|
||||
// tests. If W is set (NewSink passes the CLI's log output), Play logs one
|
||||
// line about it so "runs through silently" stays visible.
|
||||
type NullSink struct {
|
||||
W io.Writer // optionaler Log-Output; nil = still verwerfen
|
||||
W io.Writer // optional log output; nil = discard silently
|
||||
}
|
||||
|
||||
// Name implementiert Sink.
|
||||
// Name implements Sink.
|
||||
func (NullSink) Name() string { return NameNull }
|
||||
|
||||
// Play implementiert Sink: verwirft die Daten.
|
||||
// Play implements Sink: discards the data.
|
||||
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))
|
||||
fmt.Fprintf(n.W, "audio: %d bytes discarded (null sink)\n", len(data))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildWAV baut nach RIFF-Spezifikation eine WAV-Datei (unabhängig vom
|
||||
// Parser implementiert, damit Parser und Test-Fixture nicht denselben
|
||||
// Fehler teilen).
|
||||
// buildWAV builds a WAV file per the RIFF specification (implemented
|
||||
// independently of the parser so parser and test fixture cannot share the
|
||||
// same bug).
|
||||
func buildWAV(audioFormat uint16, channels, rate, bits int, samples []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("RIFF")
|
||||
@@ -44,22 +44,22 @@ func TestDecodeWAV(t *testing.T) {
|
||||
t.Fatalf("decodeWAV: %v", err)
|
||||
}
|
||||
if f.SampleRate != 22050 || f.Channels != 1 || f.BitsPerCoded != 16 || f.Float {
|
||||
t.Errorf("Format = %+v", f)
|
||||
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) {
|
||||
t.Run("8-bit stereo with 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
|
||||
// insert a LIST chunk (word-aligned, odd length) between fmt and data
|
||||
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)
|
||||
t.Errorf("format = %+v", f)
|
||||
}
|
||||
if string(pcm) != "\x07\x08\x09" {
|
||||
t.Errorf("PCM = %x", pcm)
|
||||
@@ -72,42 +72,42 @@ func TestDecodeWAV(t *testing.T) {
|
||||
t.Fatalf("decodeWAV: %v", err)
|
||||
}
|
||||
if !f.Float || f.BitsPerCoded != 32 {
|
||||
t.Errorf("Format = %+v, want Float32", f)
|
||||
t.Errorf("format = %+v, want Float32", f)
|
||||
}
|
||||
})
|
||||
t.Run("leerer data-Chunk ist erlaubt", func(t *testing.T) {
|
||||
t.Run("empty data chunk is allowed", 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)
|
||||
t.Errorf("PCM = %x, want empty", pcm)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecodeWAVFehler(t *testing.T) {
|
||||
func TestDecodeWAVErrors(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 {
|
||||
{"too short", []byte("RIFF"), "too short"},
|
||||
{"no riff", []byte("RIFX" + "\x00\x00\x00\x00" + "WAVE"), "RIFF"},
|
||||
{"24-bit pcm", buildWAV(1, 1, 44100, 24, []byte{1, 2, 3}), "unsupported format"},
|
||||
{"3 channels", buildWAV(1, 3, 44100, 16, []byte{1, 2, 3, 4, 5, 6}), "channels"},
|
||||
{"without data", func() []byte {
|
||||
wav := buildWAV(1, 1, 22050, 16, []byte{1, 2})
|
||||
return wav[:36] // nur fmt-Chunk behalten
|
||||
return wav[:36] // keep only the fmt chunk
|
||||
}(), "data"},
|
||||
{"kaputter chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "überragt"},
|
||||
{"broken chunk", append(buildWAV(1, 1, 22050, 16, nil), "data\xff\xff\xff\xff"...), "exceeds"},
|
||||
}
|
||||
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)
|
||||
t.Errorf("decodeWAV error = %v, want message containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,9 +124,9 @@ func TestNewSink(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if _, err := NewSink("mpv", nil); err == nil {
|
||||
t.Error("NewSink(\"mpv\") soll fehlschlagen")
|
||||
t.Error("NewSink(\"mpv\") should fail")
|
||||
}
|
||||
t.Run("null-Sink loggt über den Factory-Writer", func(t *testing.T) {
|
||||
t.Run("null sink logs via the factory writer", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
s, err := NewSink(NameNull, &buf)
|
||||
if err != nil {
|
||||
@@ -135,49 +135,49 @@ func TestNewSink(t *testing.T) {
|
||||
if err := s.Play(context.Background(), []byte("audio")); err != nil {
|
||||
t.Fatalf("Play: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "verworfen (null sink)") {
|
||||
t.Errorf("Log = %q, will Verwurf-Zeile über den Factory-Writer", buf.String())
|
||||
if !strings.Contains(buf.String(), "discarded (null sink)") {
|
||||
t.Errorf("log = %q, want discard line via the factory writer", buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNullSinkVerwirftUndLoggt(t *testing.T) {
|
||||
func TestNullSinkDiscardsAndLogs(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 !strings.Contains(buf.String(), "discarded") {
|
||||
t.Errorf("NullSink log = %q, want discard line", 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"))
|
||||
// TestOtoSinkInvalidData: OtoSink.Play must fail at WAV decoding before any
|
||||
// audio device is touched; this is the CI/server-safe error path of the oto
|
||||
// sink.
|
||||
func TestOtoSinkInvalidData(t *testing.T) {
|
||||
err := (OtoSink{}).Play(context.Background(), []byte("not audio"))
|
||||
if err == nil {
|
||||
t.Fatal("OtoSink.Play mit Müll: kein Fehler")
|
||||
t.Fatal("OtoSink.Play with garbage: no error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wav:") {
|
||||
t.Errorf("Fehler soll aus dem WAV-Parser kommen: %v", err)
|
||||
t.Errorf("error should come from the WAV parser: %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) {
|
||||
// TestOtoSinkRealPlayback runs only with TALK_TEST_REAL_AUDIO=1 (produces
|
||||
// real sound); default test runs stay silent.
|
||||
func TestOtoSinkRealPlayback(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Übersprungen (-short)")
|
||||
t.Skip("skipped (-short)")
|
||||
}
|
||||
if os.Getenv("TALK_TEST_REAL_AUDIO") != "1" {
|
||||
t.Skip("Übersprungen (hörbar) — mit TALK_TEST_REAL_AUDIO=1 ausführen")
|
||||
t.Skip("skipped (audible); run with TALK_TEST_REAL_AUDIO=1")
|
||||
}
|
||||
// 0,1 s 440-Hz-Sinus, 16 Bit mono 22050 Hz.
|
||||
// 0.1 s 440 Hz sine, 16 bit mono 22050 Hz.
|
||||
const rate = 22050
|
||||
samples := make([]byte, rate/10*2)
|
||||
for i := 0; i < rate/10; i++ {
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Check prüft, ob der Sink name einsatzbereit ist, ohne Ton zu erzeugen:
|
||||
// null ist immer bereit; oto initialisiert den Audio-Kontext (Standard-
|
||||
// Format 44,1 kHz Stereo 16 Bit) und wartet auf dessen Bereitschaft — so
|
||||
// fällt „kein Audiogerät" (Server, Container, fehlende PulseAudio/ALSA-
|
||||
// Bibliothek) in `talk doctor` auf, nicht erst bei der ersten Ansage.
|
||||
// Check verifies that the sink name is ready without producing sound: null
|
||||
// is always ready; oto initializes the audio context (default format 44.1
|
||||
// kHz stereo 16 bit) and waits for it to become ready, so "no audio device"
|
||||
// (servers, containers, missing PulseAudio/ALSA library) shows up in
|
||||
// `talk doctor` rather than at the first announcement.
|
||||
func Check(ctx context.Context, name string) error {
|
||||
switch name {
|
||||
case NameNull:
|
||||
@@ -23,15 +23,15 @@ func Check(ctx context.Context, name string) error {
|
||||
select {
|
||||
case <-c.ready:
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
|
||||
return fmt.Errorf("oto: initialization failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("oto: Audio-Gerät antwortet nicht (%v)", ctx.Err())
|
||||
return fmt.Errorf("oto: audio device not responding (%v)", ctx.Err())
|
||||
case <-time.After(5 * time.Second):
|
||||
return fmt.Errorf("oto: Audio-Gerät antwortet nicht binnen 5 s")
|
||||
return fmt.Errorf("oto: audio device not responding within 5 s")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unbekannter audio sink %q (erlaubt: %q, %q)", name, NameOto, NameNull)
|
||||
return fmt.Errorf("unknown audio sink %q (allowed: %q, %q)", name, NameOto, NameNull)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package audio implementiert die Audio-Ausgabe von talk über das
|
||||
// Sink-Interface: Playback embedded via oto (RIFF/WAVE-PCM und MP3, per
|
||||
// reinem Go-Dekodierer) und ein null-Sink, das verwirft (CI, Server,
|
||||
// Tests). Auswahl per Config audio.sink = "oto" | "null" (Default oto).
|
||||
// Package audio implements talk's audio output behind the Sink interface:
|
||||
// embedded playback via oto (RIFF/WAVE PCM and MP3, via a pure Go decoder)
|
||||
// and a null sink that discards (CI, servers, tests). Selected via config
|
||||
// audio.sink = "oto" | "null" (default oto).
|
||||
package audio
|
||||
+22
-24
@@ -8,23 +8,21 @@ import (
|
||||
mpg "github.com/hajimehoshi/go-mp3"
|
||||
)
|
||||
|
||||
// decodeMP3 dekodiert MP3-Daten — das Lieferformat der Cloud-Provider ab
|
||||
// Task 03 (ElevenLabs, OpenAI TTS, Edge-TTS) — in rohe PCM-Samples für
|
||||
// oto.
|
||||
// decodeMP3 decodes MP3 data, the delivery format of the cloud providers
|
||||
// (ElevenLabs, OpenAI TTS, Edge TTS), into raw PCM samples for oto.
|
||||
//
|
||||
// Entscheidung mp3-Dekodierung (Task 03): eingebauter, reiner Go-Dekoder
|
||||
// github.com/hajimehoshi/go-mp3 v0.3.4 statt WAV/PCM-Anforderung bei den
|
||||
// APIs. Begründung: (1) Alle drei Cloud-Provider liefern mp3 als ihr
|
||||
// natives Format; nur OpenAI kennt response_format "wav"/"pcm", ElevenLabs
|
||||
// und Edge-TTS aber nicht — eine einheitliche mp3-Pipeline hält die Kette
|
||||
// symmetrisch. (2) Der Dekoder ist nachweislich frei von CGO (kein
|
||||
// import "C" im Modul), damit bleibt die Cross-Compile-Verpflichtung
|
||||
// (CGO_ENABLED=0 für alle 6 Targets) unangetastet — verifiziert per
|
||||
// GOOS/GOARCH-Matrix-Build, siehe Task-03-Report.
|
||||
// Decision: a built-in pure Go decoder (github.com/hajimehoshi/go-mp3
|
||||
// v0.3.4) instead of requesting WAV/PCM from the APIs. Reasons: (1) all
|
||||
// three cloud providers deliver mp3 as their native format; only OpenAI
|
||||
// knows response_format "wav"/"pcm", ElevenLabs and Edge TTS do not, so a
|
||||
// single mp3 pipeline keeps the chain symmetric. (2) The decoder is free of
|
||||
// CGO (no import "C" in the module), which keeps the cross-compile
|
||||
// commitment (CGO_ENABLED=0 for all 6 targets) intact, verified by a
|
||||
// GOOS/GOARCH matrix build.
|
||||
//
|
||||
// Der Dekoder liefert immer 16-Bit-PCM little-endian mit 2 Kanälen — auch
|
||||
// für Mono-Quellen (etwa Edge-TTS: 24 kHz mono) wird auf Stereo
|
||||
// gespiegelt; die Sample-Rate stammt aus dem ersten MP3-Frame.
|
||||
// The decoder always yields 16-bit little-endian PCM with 2 channels; mono
|
||||
// sources (such as Edge TTS: 24 kHz mono) are mirrored to stereo. The
|
||||
// sample rate comes from the first MP3 frame.
|
||||
func decodeMP3(data []byte) (pcmFormat, []byte, error) {
|
||||
dec, err := mpg.NewDecoder(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
@@ -32,21 +30,21 @@ func decodeMP3(data []byte) (pcmFormat, []byte, error) {
|
||||
}
|
||||
pcm, err := io.ReadAll(dec)
|
||||
if err != nil {
|
||||
return pcmFormat{}, nil, fmt.Errorf("mp3: dekodieren: %w", err)
|
||||
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding: %w", err)
|
||||
}
|
||||
if len(pcm) == 0 {
|
||||
return pcmFormat{}, nil, fmt.Errorf("mp3: Dekodierung lieferte keine Samples")
|
||||
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding produced no samples")
|
||||
}
|
||||
return pcmFormat{
|
||||
SampleRate: dec.SampleRate(),
|
||||
Channels: 2, // go-mp3 dekodiert grundsätzlich 2 Kanäle (s. doc)
|
||||
Channels: 2, // go-mp3 always decodes to 2 channels (see its docs)
|
||||
BitsPerCoded: 16,
|
||||
}, pcm, nil
|
||||
}
|
||||
|
||||
// looksLikeMP3 meldet, ob data nach MP3 aussieht: ID3v2-Tag (führt mit
|
||||
// "ID3") oder ein roher Frame-Sync (erstes Byte 0xFF, oberste 3 Bits des
|
||||
// zweiten Bytes gesetzt).
|
||||
// looksLikeMP3 reports whether data looks like MP3: an ID3v2 tag (leading
|
||||
// "ID3") or a raw frame sync (first byte 0xFF, top 3 bits of the second
|
||||
// byte set).
|
||||
func looksLikeMP3(data []byte) bool {
|
||||
if len(data) >= 3 && string(data[0:3]) == "ID3" {
|
||||
return true
|
||||
@@ -54,9 +52,9 @@ func looksLikeMP3(data []byte) bool {
|
||||
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
|
||||
}
|
||||
|
||||
// decodeAudio ist die Format-Weiche der Sinks: RIFF/WAVE (PCM) nach
|
||||
// decodeWAV, MP3 (ID3-Tag oder Frame-Sync) nach decodeMP3. Alles andere
|
||||
// fällt durch zu decodeWAV, das den präzisen „kein RIFF"-Fehler liefert.
|
||||
// decodeAudio is the format switch of the sinks: RIFF/WAVE (PCM) goes to
|
||||
// decodeWAV, MP3 (ID3 tag or frame sync) to decodeMP3. Everything else
|
||||
// falls through to decodeWAV, which yields the precise "no RIFF" error.
|
||||
func decodeAudio(data []byte) (pcmFormat, []byte, error) {
|
||||
if len(data) >= 4 && string(data[0:4]) == "RIFF" {
|
||||
return decodeWAV(data)
|
||||
|
||||
+40
-44
@@ -6,16 +6,15 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Zwei einmalig erzeugte, echte MP3-Fixtures (ffmpeg am Dev-Host, in den
|
||||
// Test eingebettet — Tests bleiben zur Laufzeit frei von Werkzeug-Abhängig-
|
||||
// keiten). Beide entsprechen dem Lieferformat der Cloud-Provider aus
|
||||
// Task 03:
|
||||
// Two real MP3 fixtures generated once (ffmpeg on the dev host, embedded in
|
||||
// the test so tests stay free of tool dependencies at runtime). Both match
|
||||
// the delivery format of the cloud providers:
|
||||
//
|
||||
// fixtureMP3Mono: 0,15 s Stille, 24 kHz mono 48 kbit/s — exakt das
|
||||
// Edge-TTS-Ausgabeformat (audio-24khz-48kbitrate-mono-
|
||||
// mp3); übt den Mono→Stereo-Upsample-Pfad des Dekoders.
|
||||
// fixtureMP3Stereo: 0,15 s 440-Hz-Sinus, 44,1 kHz stereo 64 kbit/s — das
|
||||
// typische ElevenLabs/OpenAI-Format.
|
||||
// fixtureMP3Mono: 0.15 s silence, 24 kHz mono 48 kbit/s; exactly the
|
||||
// Edge TTS output format (audio-24khz-48kbitrate-mono-
|
||||
// mp3); exercises the decoder's mono-to-stereo path.
|
||||
// fixtureMP3Stereo: 0.15 s 440 Hz sine, 44.1 kHz stereo 64 kbit/s; the
|
||||
// typical ElevenLabs/OpenAI format.
|
||||
const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
|
||||
|
||||
const fixtureMP3StereoB64 = "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"
|
||||
@@ -24,14 +23,14 @@ func fixture(t *testing.T, b64 string) []byte {
|
||||
t.Helper()
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("Fixture dekodieren: %v", err)
|
||||
t.Fatalf("decoding fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// TestDecodeMP3Mono: das Edge-Format (24 kHz mono) dekodiert zu 16-Bit-PCM
|
||||
// stereo — der Dekoder spiegelt Mono auf zwei Kanäle, Samples sind 4 Bytes
|
||||
// (2 Kanäle × 2 Bytes) aligned.
|
||||
// TestDecodeMP3Mono: the Edge format (24 kHz mono) decodes to 16-bit PCM
|
||||
// stereo; the decoder mirrors mono onto two channels, samples are aligned
|
||||
// to 4 bytes (2 channels x 2 bytes).
|
||||
func TestDecodeMP3Mono(t *testing.T) {
|
||||
f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
|
||||
if err != nil {
|
||||
@@ -41,60 +40,57 @@ func TestDecodeMP3Mono(t *testing.T) {
|
||||
t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
|
||||
}
|
||||
if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
|
||||
t.Errorf("Format = %+v, want 2 Kanäle/16 Bit/int", f)
|
||||
t.Errorf("format = %+v, want 2 channels/16 bit/int", f)
|
||||
}
|
||||
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
||||
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar (16-Bit-Samples stereo)", len(pcm))
|
||||
t.Errorf("PCM length = %d, want > 0 and divisible by 4 (16-bit stereo samples)", len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMP3Stereo: ElevenLabs/OpenAI-typisches Format (44,1 kHz stereo).
|
||||
// TestDecodeMP3Stereo: the ElevenLabs/OpenAI-typical format (44.1 kHz stereo).
|
||||
func TestDecodeMP3Stereo(t *testing.T) {
|
||||
f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
|
||||
if err != nil {
|
||||
t.Fatalf("decodeMP3 (stereo): %v", err)
|
||||
}
|
||||
if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
|
||||
t.Errorf("Format = %+v, want 44100 Hz stereo 16 Bit", f)
|
||||
t.Errorf("format = %+v, want 44100 Hz stereo 16 bit", f)
|
||||
}
|
||||
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
||||
t.Errorf("PCM-Länge = %d, want > 0 und durch 4 teilbar", len(pcm))
|
||||
t.Errorf("PCM length = %d, want > 0 and divisible by 4", len(pcm))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeMP3Fehler: Daten, die nach MP3 aussehen, aber keine Frames
|
||||
// enthalten, sind ein klarer mp3-Fehler (nicht der WAV-Fehlerpfad).
|
||||
func TestDecodeMP3Fehler(t *testing.T) {
|
||||
cases := []struct{ name, want string }{
|
||||
{"Müll mit Frame-Sync", "mp3:"},
|
||||
{"ID3-Tag ohne Frames", "mp3:"},
|
||||
// TestDecodeMP3Errors: data that looks like MP3 but contains no frames is a
|
||||
// clear mp3 error (not the WAV error path).
|
||||
func TestDecodeMP3Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{"garbage with frame sync", []byte{0xFF, 0xFB, 'n', 'o', 't', ' ', 'm', 'p', '3'}, "mp3:"},
|
||||
{"ID3 tag without frames", []byte("ID3\x04\x00\x00\x00\x00\x00\x00"), "mp3:"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var data []byte
|
||||
switch tc.name {
|
||||
case "Müll mit Frame-Sync":
|
||||
data = []byte{0xFF, 0xFB, 'k', 'e', 'i', 'n', ' ', 'm', 'p', '3'}
|
||||
case "ID3-Tag ohne Frames":
|
||||
data = []byte("ID3\x04\x00\x00\x00\x00\x00\x00")
|
||||
}
|
||||
_, _, err := decodeMP3(data)
|
||||
_, _, err := decodeMP3(tc.data)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("decodeMP3-Fehler = %v, will Meldung mit %q", err, tc.want)
|
||||
t.Errorf("decodeMP3 error = %v, want message containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLooksLikeMP3 pinnt die Format-Erkennung.
|
||||
// TestLooksLikeMP3 pins the format detection.
|
||||
func TestLooksLikeMP3(t *testing.T) {
|
||||
yes := [][]byte{
|
||||
fixture(t, fixtureMP3StereoB64), // ID3-Tag
|
||||
{0xFF, 0xFB, 0x00, 0x00}, // roher Frame-Sync
|
||||
fixture(t, fixtureMP3StereoB64), // ID3 tag
|
||||
{0xFF, 0xFB, 0x00, 0x00}, // raw frame sync
|
||||
}
|
||||
no := [][]byte{
|
||||
[]byte("RIFF....WAVE"),
|
||||
[]byte("kein audio"),
|
||||
[]byte("not audio"),
|
||||
{0xFF},
|
||||
{},
|
||||
}
|
||||
@@ -110,18 +106,18 @@ func TestLooksLikeMP3(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeAudioWeiche: RIFF geht an den WAV-Parser, MP3 an den MP3-
|
||||
// Dekoder, alles andere fällt mit dem WAV-Fehler durch.
|
||||
func TestDecodeAudioWeiche(t *testing.T) {
|
||||
// TestDecodeAudioSwitch: RIFF goes to the WAV parser, MP3 to the MP3
|
||||
// decoder, everything else falls through with the WAV error.
|
||||
func TestDecodeAudioSwitch(t *testing.T) {
|
||||
f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
|
||||
if err != nil || f.SampleRate != 22050 {
|
||||
t.Errorf("decodeAudio(WAV) = %+v, %v — soll den WAV-Pfad nehmen", f, err)
|
||||
t.Errorf("decodeAudio(WAV) = %+v, %v; should take the WAV path", f, err)
|
||||
}
|
||||
f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
|
||||
if err != nil || f.SampleRate != 24000 {
|
||||
t.Errorf("decodeAudio(MP3) = %+v, %v — soll den MP3-Pfad nehmen", f, err)
|
||||
t.Errorf("decodeAudio(MP3) = %+v, %v; should take the MP3 path", f, err)
|
||||
}
|
||||
if _, _, err = decodeAudio([]byte("kein audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
|
||||
t.Errorf("decodeAudio(Müll) = %v, will WAV-Fehler", err)
|
||||
if _, _, err = decodeAudio([]byte("not audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
|
||||
t.Errorf("decodeAudio(garbage) = %v, want WAV error", err)
|
||||
}
|
||||
}
|
||||
+16
-19
@@ -10,20 +10,18 @@ import (
|
||||
"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.
|
||||
// OtoSink plays audio embedded via oto (github.com/ebitengine/oto/v3,
|
||||
// pinned to v3.5.0); no external player needed. oto loads the audio drivers
|
||||
// at runtime without CGO (purego), which keeps talk cross-compilable.
|
||||
type OtoSink struct{}
|
||||
|
||||
// Name implementiert Sink.
|
||||
// Name implements 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.
|
||||
// contextCache keeps one oto context per PCM format: oto supports only ONE
|
||||
// context per process ("Creating multiple contexts is NOT supported"), and
|
||||
// a context is bound to exactly one format. The CLI plays once per process;
|
||||
// the daemon benefits from the cache.
|
||||
var contextCache = struct {
|
||||
sync.Mutex
|
||||
byFormat map[pcmFormat]*otoContext
|
||||
@@ -34,7 +32,7 @@ type otoContext struct {
|
||||
ready chan struct{}
|
||||
}
|
||||
|
||||
// otoFormat mappt ein PCM-Format auf das oto-Format.
|
||||
// otoFormat maps a PCM format to the oto format.
|
||||
func (f pcmFormat) otoFormat() oto.Format {
|
||||
switch {
|
||||
case f.Float:
|
||||
@@ -46,7 +44,7 @@ func (f pcmFormat) otoFormat() oto.Format {
|
||||
}
|
||||
}
|
||||
|
||||
// contextFor liefert den (gecachten) oto-Kontext für ein Format.
|
||||
// contextFor returns the (cached) oto context for a format.
|
||||
func contextFor(f pcmFormat) (*otoContext, error) {
|
||||
contextCache.Lock()
|
||||
defer contextCache.Unlock()
|
||||
@@ -60,17 +58,16 @@ func contextFor(f pcmFormat) (*otoContext, error) {
|
||||
}
|
||||
ctx, ready, err := oto.NewContext(op)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oto: Kontext (Audio-Gerät?) fehlgeschlagen: %w", err)
|
||||
return nil, fmt.Errorf("oto: creating context failed (no audio device?): %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.
|
||||
// Play implements Sink: decodes the data (RIFF/WAVE PCM from osbuiltin or
|
||||
// MP3 from the cloud providers, see mp3.go) and plays the samples, blocking.
|
||||
// Audio device and format errors are reported clearly.
|
||||
func (OtoSink) Play(ctx context.Context, data []byte) error {
|
||||
f, pcm, err := decodeAudio(data)
|
||||
if err != nil {
|
||||
@@ -83,7 +80,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
|
||||
select {
|
||||
case <-c.ready:
|
||||
if err := c.ctx.Err(); err != nil {
|
||||
return fmt.Errorf("oto: Initialisierung fehlgeschlagen: %w", err)
|
||||
return fmt.Errorf("oto: initialization failed: %w", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
@@ -98,7 +95,7 @@ func (OtoSink) Play(ctx context.Context, data []byte) error {
|
||||
return err
|
||||
}
|
||||
if err := player.Err(); err != nil {
|
||||
return fmt.Errorf("oto: Wiedergabe fehlgeschlagen: %w", err)
|
||||
return fmt.Errorf("oto: playback failed: %w", err)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
+17
-18
@@ -5,28 +5,27 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// pcmFormat beschreibt rohe PCM-Samples, wie sie oto zur Wiedergabe
|
||||
// braucht.
|
||||
// pcmFormat describes raw PCM samples as oto needs them for playback.
|
||||
type pcmFormat struct {
|
||||
SampleRate int
|
||||
Channels int
|
||||
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (nur IEEE float)
|
||||
BitsPerCoded int // 8 (unsigned), 16 (signed LE), 32 (IEEE float only)
|
||||
Float bool
|
||||
}
|
||||
|
||||
// decodeWAV zerlegt RIFF/WAVE-Bytes in Format und PCM-Daten.
|
||||
// decodeWAV splits RIFF/WAVE bytes into format and PCM data.
|
||||
//
|
||||
// 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.
|
||||
// Supported: PCM (AudioFormat 1) with 8/16 bit and IEEE float (AudioFormat
|
||||
// 3) with 32 bit; mono and stereo. Everything else, compressed formats in
|
||||
// particular, is a clear error; the OS TTS paths of osbuiltin deliver
|
||||
// 16-bit PCM WAV exclusively.
|
||||
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))
|
||||
return f, nil, fmt.Errorf("wav: too short for a 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")
|
||||
return f, nil, fmt.Errorf("wav: no RIFF/WAVE header")
|
||||
}
|
||||
|
||||
var haveFmt bool
|
||||
@@ -37,12 +36,12 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
|
||||
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)
|
||||
return f, nil, fmt.Errorf("wav: chunk %q exceeds the data (size %d)", id, size)
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
if size < 16 {
|
||||
return f, nil, fmt.Errorf("wav: fmt-Chunk zu kurz (%d)", size)
|
||||
return f, nil, fmt.Errorf("wav: fmt chunk too short (%d)", size)
|
||||
}
|
||||
audioFormat := binary.LittleEndian.Uint16(data[body : body+2])
|
||||
f.Channels = int(binary.LittleEndian.Uint16(data[body+2 : body+4]))
|
||||
@@ -53,29 +52,29 @@ func decodeWAV(data []byte) (pcmFormat, []byte, error) {
|
||||
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",
|
||||
return f, nil, fmt.Errorf("wav: unsupported format (AudioFormat %d, %d bit); allowed: 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)
|
||||
return f, nil, fmt.Errorf("wav: %d channels (allowed: 1, 2)", f.Channels)
|
||||
}
|
||||
if f.SampleRate <= 0 {
|
||||
return f, nil, fmt.Errorf("wav: SampleRate %d", f.SampleRate)
|
||||
return f, nil, fmt.Errorf("wav: sample rate %d", f.SampleRate)
|
||||
}
|
||||
haveFmt = true
|
||||
case "data":
|
||||
payload = data[body : body+size]
|
||||
}
|
||||
pos = body + size
|
||||
if size%2 == 1 { // Chunks sind word-aligned
|
||||
if size%2 == 1 { // chunks are word-aligned
|
||||
pos++
|
||||
}
|
||||
}
|
||||
if !haveFmt {
|
||||
return f, nil, fmt.Errorf("wav: kein fmt-Chunk")
|
||||
return f, nil, fmt.Errorf("wav: no fmt chunk")
|
||||
}
|
||||
if payload == nil {
|
||||
return f, nil, fmt.Errorf("wav: kein data-Chunk")
|
||||
return f, nil, fmt.Errorf("wav: no data chunk")
|
||||
}
|
||||
return f, payload, nil
|
||||
}
|
||||
Reference in new issue
Block a user