936c6d71db
The WAV parser now walks chunk headers to find the data chunk instead of scanning for the four bytes "data". A LIST chunk between fmt and data is common, arecord and ffmpeg both write one, and its payload is free text that can spell the word. A byte scan took that text for a chunk header and read the comment as samples. WAVHeader named WAVFromPCM in its error, so a caller of WAVHeader read the wrong function. internal/capture calls it twice. PCMFromWAV returns PCM that aliases the buffer it was given. That is the right trade for a long recording and it was undocumented, so the doc comment now says so and names the two ways a caller gets it wrong. The TTS stub wrote 16000 three times. It reads the rate and the sample width off audio.PCM16kMono now, so the tone stays in tune with the shape the seam declares, and the sample write goes through binary.LittleEndian. Identify computed the clip length twice to report it once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
159 lines
6.6 KiB
Go
159 lines
6.6 KiB
Go
// audio/pcmwav.go — tiny WAV ⇄ PCM helpers.
|
|
//
|
|
// The reference client (cmd/mavenclient) speaks WAV on disk (`arecord -f`
|
|
// produces it; `aplay` plays it) and raw PCM on the wire (audio.Audio.Bytes
|
|
// is headerless per the audio package). These helpers do the 44-byte
|
|
// canonical-PCM-WAV strip/produce dance — a full WAV parser is overkill for
|
|
// a single fixed format, and pulling in a third-party WAV library violates
|
|
// the "stdlib + modernc only" floor.
|
|
//
|
|
// Only canonical 16-bit PCM mono WAV (format 1, channel count = 1, bitsPerSample
|
|
// = 16) is supported. A non-conforming WAV is rejected with ErrNotCanonicalPCM
|
|
// rather than silently mis-routing — the client should produce canonical
|
|
// audio (arecord -f cd -r 16000 default; or mavenclient -r) and the helper
|
|
// refuses anything else so we don't ship garbage to a model expecting 16k mono.
|
|
package audio
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
)
|
|
|
|
// ErrNotCanonicalPCM — the WAV blob isn't canonical 16-bit mono PCM. Either
|
|
// it's a different container (RIFF WAVE with a different format code), wrong
|
|
// bit depth, or multi-channel. Refused at the seam rather than resampled —
|
|
// resampling on the always-on box wastes cycles, and the client is in the
|
|
// better position to do it (it has the platform audio stack).
|
|
var ErrNotCanonicalPCM = errors.New("audio: not canonical 16-bit mono PCM WAV")
|
|
|
|
// wavHeaderSize — the canonical 44-byte PCM WAV header (RIFF + fmt + data,
|
|
// each chunk exactly minimum-size). Everything else is an extension we
|
|
// don't read and shouldn't accept silently.
|
|
const wavHeaderSize = 44
|
|
|
|
// PCMFromWAV strips a canonical 16-bit mono PCM WAV header and returns the
|
|
// 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))
|
|
}
|
|
if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
|
|
return Format{}, nil, fmt.Errorf("%w: missing RIFF/WAVE", ErrNotCanonicalPCM)
|
|
}
|
|
if string(wav[12:16]) != "fmt " {
|
|
return Format{}, nil, fmt.Errorf("%w: missing fmt chunk", ErrNotCanonicalPCM)
|
|
}
|
|
fmtSize := binary.LittleEndian.Uint32(wav[16:20])
|
|
if fmtSize != 16 {
|
|
return Format{}, nil, fmt.Errorf("%w: fmt chunk size %d (not 16)", ErrNotCanonicalPCM, fmtSize)
|
|
}
|
|
audioFormat := binary.LittleEndian.Uint16(wav[20:22])
|
|
if audioFormat != 1 {
|
|
return Format{}, nil, fmt.Errorf("%w: format code %d (not PCM=1)", ErrNotCanonicalPCM, audioFormat)
|
|
}
|
|
channels := int(binary.LittleEndian.Uint16(wav[22:24]))
|
|
sampleRate := int(binary.LittleEndian.Uint32(wav[24:28]))
|
|
bitsPerSample := int(binary.LittleEndian.Uint16(wav[34:36]))
|
|
if channels != 1 || bitsPerSample != 16 {
|
|
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). 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:]
|
|
if dataSize != 0 && int(dataSize) < len(body) {
|
|
body = body[:dataSize]
|
|
}
|
|
f := Format{
|
|
SampleRate: sampleRate,
|
|
Channels: 1,
|
|
SampleBits: 16,
|
|
Encoding: "pcm_s16le",
|
|
}
|
|
if !f.IsValid() {
|
|
return Format{}, nil, fmt.Errorf("%w: rate %d (want 16000)", ErrNotCanonicalPCM, sampleRate)
|
|
}
|
|
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.
|
|
func WAVFromPCM(format Format, pcm []byte) ([]byte, error) {
|
|
hdr, err := WAVHeader(format, len(pcm))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]byte, wavHeaderSize+len(pcm))
|
|
copy(out, hdr)
|
|
copy(out[wavHeaderSize:], pcm)
|
|
return out, nil
|
|
}
|
|
|
|
// WAVHeaderSize is the fixed size of the header WAVHeader writes. A caller
|
|
// spooling audio to a file reserves this many bytes up front and rewrites them
|
|
// once it knows the length.
|
|
const WAVHeaderSize = wavHeaderSize
|
|
|
|
// WAVHeader builds just the 44-byte canonical header for n bytes of PCM. It
|
|
// exists so a long recording can be written straight to a file: holding the
|
|
// whole meeting in memory to prepend 44 bytes is what the streaming path is
|
|
// avoiding.
|
|
func WAVHeader(format Format, n int) ([]byte, error) {
|
|
if !format.IsValid() {
|
|
return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format)
|
|
}
|
|
out := make([]byte, wavHeaderSize)
|
|
// RIFF header
|
|
copy(out[0:4], []byte("RIFF"))
|
|
binary.LittleEndian.PutUint32(out[4:8], uint32(36+n))
|
|
copy(out[8:12], []byte("WAVE"))
|
|
// fmt chunk
|
|
copy(out[12:16], []byte("fmt "))
|
|
binary.LittleEndian.PutUint32(out[16:20], 16) // fmt chunk size
|
|
binary.LittleEndian.PutUint16(out[20:22], 1) // PCM
|
|
binary.LittleEndian.PutUint16(out[22:24], uint16(format.Channels))
|
|
binary.LittleEndian.PutUint32(out[24:28], uint32(format.SampleRate))
|
|
byteRate := uint32(format.SampleRate) * uint32(format.Channels) * uint32(format.SampleBits) / 8
|
|
binary.LittleEndian.PutUint32(out[28:32], byteRate)
|
|
blockAlign := uint16(format.Channels) * uint16(format.SampleBits) / 8
|
|
binary.LittleEndian.PutUint16(out[32:34], blockAlign)
|
|
binary.LittleEndian.PutUint16(out[34:36], uint16(format.SampleBits))
|
|
// data chunk
|
|
copy(out[36:40], []byte("data"))
|
|
binary.LittleEndian.PutUint32(out[40:44], uint32(n))
|
|
return out, nil
|
|
}
|