Files
Maven/internal/audio/pcmwav.go
kami 71b42e31bd media: move a file into the store instead of reading it in
Put takes a []byte, so storing a recording meant the whole recording in
memory. A two hour meeting at 16 kHz mono is about 230 MB of WAV, and
building it from PCM held a second copy of the same size in the process
that also owns the database and the resident model. PutFile stats the
file, hashes it in a stream and renames it into place, so the peak is one
buffer regardless of length. SpoolFile hands out the scratch file it
moves from, under the media dir so it shares the same disk and the same
permissions.

Audio also gets its own per blob cap of 512 MiB. The image cap of 64 MiB
is 35 minutes of audio, which contradicted the two hour session cap: the
long meeting was exactly the one that failed to store.

audio.WAVHeader is split out of WAVFromPCM because a spooled capture
writes a placeholder header first and stamps the real length at the end.

Found in review of #73.
2026-08-01 14:36:17 +04:00

141 lines
5.8 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.
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
}