initial commit

This commit is contained in:
kami
2026-07-03 00:32:48 +02:00
commit 612583d59a
92 changed files with 14521 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
// Package audio is maven's shared audio types.
//
// Audio crosses two boundaries in maven, and only two:
//
// - client → core (the PushToTalk payload over internal/voice): raw audio
// bytes the client captured after wake-word + VAD (per the spec's "clients
// do capture, server transcribes" decision). one clean blob per utterance.
// - core → worker module (/internal/worker for stt + tts): raw bytes the
// server ships to / from the transcription + synthesis modules. core is
// the worker's client here; the worker never reaches back.
//
// Format is fixed across both surfaces at scaffold time: 16 kHz mono int16
// little-endian PCM. faster-whisper, vosk, silero, and piper all work with
// 16 kHz mono; picking one shape up-front keeps the wire a single format
// field rather than a negotiation. A different rate upstream (e.g. an 8 kHz
// phone codec) downmixes at the client before send, never on the server —
// resampling on the always-on box is wasted work.
//
// Bytes are raw PCM, NOT a container (no WAV header on the wire). The client
// strips/generates WAV headers locally so the server's worker modules get
// exactly the bytes their model expects — there is no useful reason to ship
// a 44-byte header through a length-prefixed JSON frame. internal/pcmwav
// (a tiny helper below) is the WAV ⇄ PCM pair the reference client uses.
package audio
// Format describes raw PCM audio. Fixed at scaffold time; the wire carries
// it but today only one value is meaningful. Future: a negotiated registry
// if a second codec lands (e.g. opus, for a low-bitrate phone PWA path).
type Format struct {
SampleRate int `json:"sample_rate"` // samples/sec; default 16000
Channels int `json:"channels"` // 1 = mono
SampleBits int `json:"sample_bits"` // 16 ⇒ int16 little-endian PCM
Encoding string `json:"encoding"` // "pcm_s16le" is the only value today
}
// PCM16kMono — the canonical maven audio shape. faster-whisper, silero,
// vosk, piper all consume it. Set as the default at every seam; the wire
// carries the explicit fields so a second format doesn't need a protocol
// version bump when it lands, just a new value here.
var PCM16kMono = Format{
SampleRate: 16000,
Channels: 1,
SampleBits: 16,
Encoding: "pcm_s16le",
}
// Audio — one blob. Bytes is raw PCM in Format (no container header). The
// caller that built it (client capture, TTS synthesis output, a stub) knows
// the duration from len(Bytes) / Format.bytesPerSample() / SampleRate.
type Audio struct {
Format Format `json:"format"`
Bytes []byte `json:"bytes"` // raw PCM; base64 over the wire via worker/voice JSON marshal
}
// Duration returns the playback duration implied by Bytes + Format. Returns
// 0 for empty Bytes or an unknown encoding. A sanity helper, not a contract:
// callers that need the duration for display use this; callers that need it
// for real (e.g. cooldown math) read it from the facts table, not the audio.
func (a Audio) Duration() float64 {
if len(a.Bytes) == 0 {
return 0
}
if a.Format.SampleRate <= 0 || a.Format.Channels <= 0 || a.Format.SampleBits <= 0 {
return 0
}
if a.Format.Encoding != "pcm_s16le" {
return 0
}
bytesPerSample := a.Format.SampleBits / 8
if bytesPerSample == 0 {
return 0
}
samples := float64(len(a.Bytes)) / float64(bytesPerSample*a.Format.Channels)
return samples / float64(a.Format.SampleRate)
}
// IsValid reports whether f is a format maven can route today. Returns true
// only for the single canonical shape; a value with a different encoding is
// refused at the seam rather than mis-routed to a model that expects
// something else.
func (f Format) IsValid() bool {
return f.SampleRate == PCM16kMono.SampleRate &&
f.Channels == PCM16kMono.Channels &&
f.SampleBits == PCM16kMono.SampleBits &&
f.Encoding == PCM16kMono.Encoding
}
+104
View File
@@ -0,0 +1,104 @@
package audio
import (
"bytes"
"encoding/binary"
"testing"
)
func TestAudioDuration(t *testing.T) {
t.Parallel()
// 16k mono int16 ⇒ 2 bytes/sample ⇒ 32000 bytes/second.
a := Audio{Format: PCM16kMono, Bytes: make([]byte, 32000)}
if got, want := a.Duration(), 1.0; got != want {
t.Fatalf("Duration: got %v, want %v", got, want)
}
// 4 seconds
a.Bytes = make([]byte, 128000)
if got, want := a.Duration(), 4.0; got != want {
t.Fatalf("Duration: got %v, want %v", got, want)
}
}
func TestAudioDurationEmptyAndBad(t *testing.T) {
t.Parallel()
if (Audio{}).Duration() != 0 {
t.Fatalf("empty audio: Duration should be 0")
}
a := Audio{Format: Format{}, Bytes: make([]byte, 32000)}
if a.Duration() != 0 {
t.Fatalf("zero format: Duration should be 0")
}
a = Audio{Format: Format{SampleRate: 16000, Channels: 1, SampleBits: 16, Encoding: "opus"}, Bytes: make([]byte, 32000)}
if a.Duration() != 0 {
t.Fatalf("unsupported encoding: Duration should be 0")
}
}
func TestFormatIsValid(t *testing.T) {
t.Parallel()
if !PCM16kMono.IsValid() {
t.Fatalf("PCM16kMono should be valid")
}
if (Format{SampleRate: 8000, Channels: 1, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() {
t.Fatalf("8k should be rejected")
}
if (Format{SampleRate: 16000, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}).IsValid() {
t.Fatalf("stereo should be rejected")
}
}
func TestWAVRoundTrip(t *testing.T) {
t.Parallel()
// 0.5s of square wave (alternating samples) — sndfile/aplay can play it.
const nsamp = 8000
pcm := make([]byte, nsamp*2)
for i := 0; i < nsamp; i++ {
var v int16 = -16384
if i%2 == 0 {
v = 16384
}
binary.LittleEndian.PutUint16(pcm[i*2:], uint16(v))
}
wav, err := WAVFromPCM(PCM16kMono, pcm)
if err != nil {
t.Fatalf("WAVFromPCM: %v", err)
}
if len(wav) != 44+len(pcm) {
t.Fatalf("wav length: got %d, want %d", len(wav), 44+len(pcm))
}
if string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
t.Fatalf("missing RIFF/WAVE marker: %q", wav[0:12])
}
f, pcm2, err := PCMFromWAV(wav)
if err != nil {
t.Fatalf("PCMFromWAV: %v", err)
}
if !f.IsValid() {
t.Fatalf("parsed format invalid: %+v", f)
}
if !bytes.Equal(pcm, pcm2) {
t.Fatalf("PCM mismatch after round-trip: in=%d bytes, out=%d bytes", len(pcm), len(pcm2))
}
}
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
t.Parallel()
// too short
if _, _, err := PCMFromWAV([]byte("RIFF")); err == nil {
t.Fatalf("short input should error")
}
// bad RIFF marker
bad := make([]byte, 44)
copy(bad[0:4], []byte("RIFF"))
copy(bad[8:12], []byte("XXXX"))
if _, _, err := PCMFromWAV(bad); err == nil {
t.Fatalf("non-WAVE marker should error")
}
// format code 3 (float), canonical otherwise
wav, _ := WAVFromPCM(PCM16kMono, []byte{0, 0})
binary.LittleEndian.PutUint16(wav[20:22], 3) // IEEE float, not PCM
if _, _, err := PCMFromWAV(wav); err == nil {
t.Fatalf("non-PCM format should error")
}
}
+121
View File
@@ -0,0 +1,121 @@
// 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
}