Merge the audio, speaker, stt and tts sweep (#249)

PCMFromWAV found the data chunk by scanning forward byte by byte from offset 36
for the literal data. A LIST or INFO chunk between fmt and data is common, both
arecord and ffmpeg write one, and its payload is free text that can contain that
word. So the parser could take a comment for a chunk header and read it as
samples. It walks chunk headers with word alignment now, and a new test builds
exactly that file.

Three smaller things. WAVHeader named a different function in its error, which
matters because internal/capture calls it directly twice. The tts stub wrote
16000 three times and now reads the rate and the sample width off
audio.PCM16kMono. PCMFromWAV returns a subslice of the caller's buffer, which is
the right trade for a long recording and was undocumented.

The agent refuted three of the brief's premises. The lexicon two-pass loop is
correct for any run length, because the first pass takes every other name and
frees both boundaries of the ones it skipped, measured at runs of three, four
and five. There is no duration-to-byte truncation here, since every length is a
float64 in seconds. There is no resampler and no subprocess in these four
packages.

The offload contract is not touched here. stt.Remote and tts.Remote are plain
worker clients, and the workstation preference lives in modelSeam and the
phraser.

(V-581)
This commit is contained in:
2026-08-06 03:29:22 +04:00
5 changed files with 76 additions and 19 deletions
+32
View File
@@ -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) { func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
t.Parallel() t.Parallel()
// too short // too short
+30 -12
View File
@@ -36,6 +36,10 @@ const wavHeaderSize = 44
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is // raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam // rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
// so the caller surfaces it, not a hidden silent downmix. // 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) { func PCMFromWAV(wav []byte) (Format, []byte, error) {
if len(wav) < wavHeaderSize { if len(wav) < wavHeaderSize {
return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav)) 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) 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 // data chunk: the spec mandates it appears right after fmt, but real
// recorders sometimes append extra chunks (LIST, fact). Find the "data" // recorders sometimes append extra chunks (LIST, fact). Walk the chunk
// chunk by scanning; require it within the region we'd expect. // headers rather than scanning for the four bytes "data", because those
dataIdx := -1 // bytes occur inside a LIST/INFO payload as ordinary text and a byte scan
for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ { // would take the middle of a comment for a chunk header.
if string(wav[i:i+4]) == "data" { dataIdx, err := findDataChunk(wav)
dataIdx = i if err != nil {
break return Format{}, nil, err
}
}
if dataIdx < 0 {
return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
} }
dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8]) dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
body := wav[dataIdx+8:] body := wav[dataIdx+8:]
@@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
return f, body, nil 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 // 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`. // 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. // Used by the reference client to write the TTS reply; not on the wire.
@@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize
// avoiding. // avoiding.
func WAVHeader(format Format, n int) ([]byte, error) { func WAVHeader(format Format, n int) ([]byte, error) {
if !format.IsValid() { 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) out := make([]byte, wavHeaderSize)
// RIFF header // RIFF header
+2 -2
View File
@@ -75,8 +75,8 @@ func (r *Recognizer) Identify(ctx context.Context, a audio.Audio) (Match, error)
if !a.Format.IsValid() { if !a.Format.IsValid() {
return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format) return Match{}, fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
} }
if seconds(a) < r.minSec { if sec := seconds(a); sec < r.minSec {
return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, seconds(a), r.minSec) return Match{}, fmt.Errorf("%w: %.1fs, need %.1fs", ErrTooShort, sec, r.minSec)
} }
vec, err := r.embed(ctx, a) vec, err := r.embed(ctx, a)
if err != nil { if err != nil {
+3
View File
@@ -23,6 +23,9 @@ func TestLexiconRewritesNames(t *testing.T) {
// Two names in a row share the space between them, which one pass // Two names in a row share the space between them, which one pass
// would consume. // would consume.
{"GPU GPU", "джи-пи-ю джи-пи-ю"}, {"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. // Not a word boundary: a name inside a longer token is left alone.
{"vikunjaless", "vikunjaless"}, {"vikunjaless", "vikunjaless"},
{"ничего не совпало", "ничего не совпало"}, {"ничего не совпало", "ничего не совпало"},
+9 -5
View File
@@ -20,6 +20,7 @@ package tts
import ( import (
"context" "context"
"encoding/binary"
"fmt" "fmt"
"math" "math"
@@ -50,17 +51,20 @@ func NewStub() *Stub { return &Stub{} }
// silent no-op a bug could hide behind). // silent no-op a bug could hide behind).
func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) { func (s *Stub) Synthesize(_ context.Context, text string) (audio.Audio, error) {
const durMs = 200 const durMs = 200
const samples = 16000 * durMs / 1000 // 3200 samples @ 16k // The rate is read off the canonical format rather than written again, so
pcm := make([]byte, samples*2) // 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 freq := 220.0 // A3
if len(text) > 0 { if len(text) > 0 {
freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band freq = 180.0 + float64(text[0]%6)*60 // 180..480 Hz band
} }
for i := 0; i < samples; i++ { 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)) v := int16(12000 * math.Sin(2*math.Pi*freq*t))
pcm[i*2] = byte(v) binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
pcm[i*2+1] = byte(v >> 8)
} }
return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil return audio.Audio{Format: audio.PCM16kMono, Bytes: pcm}, nil
} }