// 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. 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). 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) } 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 } // 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: WAVFromPCM: %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 }