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:
@@ -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
|
||||
|
||||
+30
-12
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"},
|
||||
{"ничего не совпало", "ничего не совпало"},
|
||||
|
||||
+9
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user