talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
124 lines
8.1 KiB
Go
124 lines
8.1 KiB
Go
package audio
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Two real MP3 fixtures generated once (ffmpeg on the dev host, embedded in
|
|
// the test so tests stay free of tool dependencies at runtime). Both match
|
|
// the delivery format of the cloud providers:
|
|
//
|
|
// fixtureMP3Mono: 0.15 s silence, 24 kHz mono 48 kbit/s; exactly the
|
|
// Edge TTS output format (audio-24khz-48kbitrate-mono-
|
|
// mp3); exercises the decoder's mono-to-stereo path.
|
|
// fixtureMP3Stereo: 0.15 s 440 Hz sine, 44.1 kHz stereo 64 kbit/s; the
|
|
// typical ElevenLabs/OpenAI format.
|
|
const fixtureMP3MonoB64 = "SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjYwLjE2LjEwMQAAAAAAAAAAAAAA//OEwAAAAAAAAAAAAEluZm8AAAAPAAAACQAABdAAOTk5OTk5OTk5OTlSUlJSUlJSUlJSUmtra2tra2tra2trhISEhISEhISEhIScnJycnJycnJycnLW1tbW1tbW1tbW1zs7Ozs7Ozs7Ozs7n5+fn5+fn5+fn5///////////////AAAAAExhdmM2MC4zMQAAAAAAAAAAAAAAACQD8AAAAAAAAAXQx638BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//NkxAAAAANIAAAAAExBTUVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVMQU1FNC4wVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV//NkxHwAAANIAAAAAFVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV"
|
|
|
|
const fixtureMP3StereoB64 = "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"
|
|
|
|
func fixture(t *testing.T, b64 string) []byte {
|
|
t.Helper()
|
|
data, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
t.Fatalf("decoding fixture: %v", err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
// TestDecodeMP3Mono: the Edge format (24 kHz mono) decodes to 16-bit PCM
|
|
// stereo; the decoder mirrors mono onto two channels, samples are aligned
|
|
// to 4 bytes (2 channels x 2 bytes).
|
|
func TestDecodeMP3Mono(t *testing.T) {
|
|
f, pcm, err := decodeMP3(fixture(t, fixtureMP3MonoB64))
|
|
if err != nil {
|
|
t.Fatalf("decodeMP3 (mono): %v", err)
|
|
}
|
|
if f.SampleRate != 24000 {
|
|
t.Errorf("SampleRate = %d, want 24000", f.SampleRate)
|
|
}
|
|
if f.Channels != 2 || f.BitsPerCoded != 16 || f.Float {
|
|
t.Errorf("format = %+v, want 2 channels/16 bit/int", f)
|
|
}
|
|
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
|
t.Errorf("PCM length = %d, want > 0 and divisible by 4 (16-bit stereo samples)", len(pcm))
|
|
}
|
|
}
|
|
|
|
// TestDecodeMP3Stereo: the ElevenLabs/OpenAI-typical format (44.1 kHz stereo).
|
|
func TestDecodeMP3Stereo(t *testing.T) {
|
|
f, pcm, err := decodeMP3(fixture(t, fixtureMP3StereoB64))
|
|
if err != nil {
|
|
t.Fatalf("decodeMP3 (stereo): %v", err)
|
|
}
|
|
if f.SampleRate != 44100 || f.Channels != 2 || f.BitsPerCoded != 16 {
|
|
t.Errorf("format = %+v, want 44100 Hz stereo 16 bit", f)
|
|
}
|
|
if len(pcm) == 0 || len(pcm)%4 != 0 {
|
|
t.Errorf("PCM length = %d, want > 0 and divisible by 4", len(pcm))
|
|
}
|
|
}
|
|
|
|
// TestDecodeMP3Errors: data that looks like MP3 but contains no frames is a
|
|
// clear mp3 error (not the WAV error path).
|
|
func TestDecodeMP3Errors(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
data []byte
|
|
want string
|
|
}{
|
|
{"garbage with frame sync", []byte{0xFF, 0xFB, 'n', 'o', 't', ' ', 'm', 'p', '3'}, "mp3:"},
|
|
{"ID3 tag without frames", []byte("ID3\x04\x00\x00\x00\x00\x00\x00"), "mp3:"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, _, err := decodeMP3(tc.data)
|
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("decodeMP3 error = %v, want message containing %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLooksLikeMP3 pins the format detection.
|
|
func TestLooksLikeMP3(t *testing.T) {
|
|
yes := [][]byte{
|
|
fixture(t, fixtureMP3StereoB64), // ID3 tag
|
|
{0xFF, 0xFB, 0x00, 0x00}, // raw frame sync
|
|
}
|
|
no := [][]byte{
|
|
[]byte("RIFF....WAVE"),
|
|
[]byte("not audio"),
|
|
{0xFF},
|
|
{},
|
|
}
|
|
for _, d := range yes {
|
|
if !looksLikeMP3(d) {
|
|
t.Errorf("looksLikeMP3(%x) = false, want true", d[:min(4, len(d))])
|
|
}
|
|
}
|
|
for _, d := range no {
|
|
if looksLikeMP3(d) {
|
|
t.Errorf("looksLikeMP3(%q) = true, want false", d)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecodeAudioSwitch: RIFF goes to the WAV parser, MP3 to the MP3
|
|
// decoder, everything else falls through with the WAV error.
|
|
func TestDecodeAudioSwitch(t *testing.T) {
|
|
f, _, err := decodeAudio(buildWAV(1, 1, 22050, 16, []byte{1, 2, 3, 4}))
|
|
if err != nil || f.SampleRate != 22050 {
|
|
t.Errorf("decodeAudio(WAV) = %+v, %v; should take the WAV path", f, err)
|
|
}
|
|
f, _, err = decodeAudio(fixture(t, fixtureMP3MonoB64))
|
|
if err != nil || f.SampleRate != 24000 {
|
|
t.Errorf("decodeAudio(MP3) = %+v, %v; should take the MP3 path", f, err)
|
|
}
|
|
if _, _, err = decodeAudio([]byte("not audio")); err == nil || !strings.Contains(err.Error(), "wav:") {
|
|
t.Errorf("decodeAudio(garbage) = %v, want WAV error", err)
|
|
}
|
|
}
|