121 lines
5.2 KiB
Go
121 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
|
|
} |