Files
Maven/internal/audio/pcmwav.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

122 lines
5.2 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) {
if !format.IsValid() {
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
}
out := make([]byte, wavHeaderSize+len(pcm))
copy(out[wavHeaderSize:], pcm)
// RIFF header
copy(out[0:4], []byte("RIFF"))
binary.LittleEndian.PutUint32(out[4:8], uint32(36+len(pcm)))
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(len(pcm)))
return out, nil
}