diff --git a/internal/audio/audio_test.go b/internal/audio/audio_test.go index fea8f2e..d010035 100644 --- a/internal/audio/audio_test.go +++ b/internal/audio/audio_test.go @@ -82,6 +82,38 @@ func TestWAVRoundTrip(t *testing.T) { } } +// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both +// write one), and its payload is free text that can spell "data". The parser +// walks chunk headers, so the text is skipped and the real samples are read. +func TestPCMFromWAVSkipsLISTChunk(t *testing.T) { + t.Parallel() + pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0} + list := []byte("LIST") + payload := []byte("INFOICMTdata is not here") + list = binary.LittleEndian.AppendUint32(list, uint32(len(payload))) + list = append(list, payload...) + + plain, err := WAVFromPCM(PCM16kMono, pcm) + if err != nil { + t.Fatalf("WAVFromPCM: %v", err) + } + wav := append([]byte{}, plain[:36]...) + wav = append(wav, list...) + wav = append(wav, plain[36:]...) + binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8)) + + f, got, err := PCMFromWAV(wav) + if err != nil { + t.Fatalf("PCMFromWAV: %v", err) + } + if !f.IsValid() { + t.Fatalf("parsed format invalid: %+v", f) + } + if !bytes.Equal(got, pcm) { + t.Fatalf("PCM mismatch: got %v, want %v", got, pcm) + } +} + func TestPCMFromWAVRejectsNonCanonical(t *testing.T) { t.Parallel() // too short diff --git a/internal/audio/pcmwav.go b/internal/audio/pcmwav.go index b9da0f8..27c58ae 100644 --- a/internal/audio/pcmwav.go +++ b/internal/audio/pcmwav.go @@ -36,6 +36,10 @@ const wavHeaderSize = 44 // raw PCM samples (little-endian int16 as bytes). A non-canonical blob is // rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam // so the caller surfaces it, not a hidden silent downmix. +// +// The returned PCM aliases wav rather than copying it, because a recording is +// large and the caller already owns the bytes. A caller that keeps the PCM past +// the life of wav, or that reuses wav as a read buffer, must copy first. func PCMFromWAV(wav []byte) (Format, []byte, error) { if len(wav) < wavHeaderSize { return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav)) @@ -61,17 +65,13 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) { return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample) } // data chunk: the spec mandates it appears right after fmt, but real - // recorders sometimes append extra chunks (LIST, fact). Find the "data" - // chunk by scanning; require it within the region we'd expect. - dataIdx := -1 - for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ { - if string(wav[i:i+4]) == "data" { - dataIdx = i - break - } - } - if dataIdx < 0 { - return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM) + // recorders sometimes append extra chunks (LIST, fact). Walk the chunk + // headers rather than scanning for the four bytes "data", because those + // bytes occur inside a LIST/INFO payload as ordinary text and a byte scan + // would take the middle of a comment for a chunk header. + dataIdx, err := findDataChunk(wav) + if err != nil { + return Format{}, nil, err } dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8]) body := wav[dataIdx+8:] @@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) { return f, body, nil } +// findDataChunk returns the offset of the "data" chunk header, walking the +// chunk list that starts after the 16-byte fmt chunk. Chunks are word-aligned, +// so an odd size carries one pad byte the next header sits behind. +func findDataChunk(wav []byte) (int, error) { + for pos := wavHeaderSize - 8; pos+8 <= len(wav); { + size := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8])) + if string(wav[pos:pos+4]) == "data" { + return pos, nil + } + next := pos + 8 + size + size%2 + if next <= pos || next > len(wav) { + break + } + pos = next + } + return 0, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM) +} + // WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV // header so the result can be written to disk and played with `aplay`. // Used by the reference client to write the TTS reply; not on the wire. @@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize // avoiding. func WAVHeader(format Format, n int) ([]byte, error) { if !format.IsValid() { - return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format) + return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format) } out := make([]byte, wavHeaderSize) // RIFF header diff --git a/internal/speaker/recognizer.go b/internal/speaker/recognizer.go index d881de5..e8d8f6f 100644 --- a/internal/speaker/recognizer.go +++ b/internal/speaker/recognizer.go @@ -75,8 +75,8 @@ func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error) if !a.Format.IsValid() { return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format) } - if seconds(a) < r.minSec { - return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec) + if sec := seconds(a); sec < r.minSec { + return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec) } vec, err := r.embed(ctx, a) if err != nil { diff --git a/internal/tts/lexicon_test.go b/internal/tts/lexicon_test.go index 8dbd175..65f6a46 100644 --- a/internal/tts/lexicon_test.go +++ b/internal/tts/lexicon_test.go @@ -23,6 +23,9 @@ func TestLexiconRewritesNames(t *testing.T) { // Two names in a row share the space between them, which one pass // would consume. {"GPU GPU", "джи-пи-ю джи-пи-ю"}, + // Two passes cover a run of any length, because the first pass takes + // every other name and leaves both boundaries of the ones it skipped. + {"GPU GPU GPU GPU", "джи-пи-ю джи-пи-ю джи-пи-ю джи-пи-ю"}, // Not a word boundary: a name inside a longer token is left alone. {"vikunjaless", "vikunjaless"}, {"ничего не совпало", "ничего не совпало"}, diff --git a/internal/tts/tts.go b/internal/tts/tts.go index 4fae0ab..0675aba 100644 --- a/internal/tts/tts.go +++ b/internal/tts/tts.go @@ -20,6 +20,7 @@ package tts import ( "context" + "encoding/binary" "fmt" "math" @@ -50,17 +51,20 @@ func NewStub() *Stub { return &Stub{} } // silent no-op a bug could hide behind). func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) { const durMs = 200 - const samples = 16000 * durMs / 1000 // 3200 samples @ 16k - pcm := make([]byte, samples*2) + // The rate is read off the canonical format rather than written again, so + // the tone stays in tune with the shape the seam declares. + rate := audio.PCM16kMono.SampleRate + bytesPerSample := audio.PCM16kMono.SampleBits / 8 + samples := rate * durMs / 1000 + pcm := make([]byte, samples*bytesPerSample) freq := 220.0 // A3 if len(text) > 0 { freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band } for i := 0; i < samples; i++ { - t := float64(i) / 16000.0 + t := float64(i) / float64(rate) v := int16(12000 * math.Sin(2*math.Pi*freq*t)) - pcm[i*2] = byte(v) - pcm[i*2+1] = byte(v >> 8) + binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v)) } return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil }