initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user