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>
67 lines
2.2 KiB
Go
67 lines
2.2 KiB
Go
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
|
|
mpg "github.com/hajimehoshi/go-mp3"
|
|
)
|
|
|
|
// decodeMP3 decodes MP3 data, the delivery format of the cloud providers
|
|
// (ElevenLabs, OpenAI TTS, Edge TTS), into raw PCM samples for oto.
|
|
//
|
|
// Decision: a built-in pure Go decoder (github.com/hajimehoshi/go-mp3
|
|
// v0.3.4) instead of requesting WAV/PCM from the APIs. Reasons: (1) all
|
|
// three cloud providers deliver mp3 as their native format; only OpenAI
|
|
// knows response_format "wav"/"pcm", ElevenLabs and Edge TTS do not, so a
|
|
// single mp3 pipeline keeps the chain symmetric. (2) The decoder is free of
|
|
// CGO (no import "C" in the module), which keeps the cross-compile
|
|
// commitment (CGO_ENABLED=0 for all 6 targets) intact, verified by a
|
|
// GOOS/GOARCH matrix build.
|
|
//
|
|
// The decoder always yields 16-bit little-endian PCM with 2 channels; mono
|
|
// sources (such as Edge TTS: 24 kHz mono) are mirrored to stereo. The
|
|
// sample rate comes from the first MP3 frame.
|
|
func decodeMP3(data []byte) (pcmFormat, []byte, error) {
|
|
dec, err := mpg.NewDecoder(bytes.NewReader(data))
|
|
if err != nil {
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: %w", err)
|
|
}
|
|
pcm, err := io.ReadAll(dec)
|
|
if err != nil {
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding: %w", err)
|
|
}
|
|
if len(pcm) == 0 {
|
|
return pcmFormat{}, nil, fmt.Errorf("mp3: decoding produced no samples")
|
|
}
|
|
return pcmFormat{
|
|
SampleRate: dec.SampleRate(),
|
|
Channels: 2, // go-mp3 always decodes to 2 channels (see its docs)
|
|
BitsPerCoded: 16,
|
|
}, pcm, nil
|
|
}
|
|
|
|
// looksLikeMP3 reports whether data looks like MP3: an ID3v2 tag (leading
|
|
// "ID3") or a raw frame sync (first byte 0xFF, top 3 bits of the second
|
|
// byte set).
|
|
func looksLikeMP3(data []byte) bool {
|
|
if len(data) >= 3 && string(data[0:3]) == "ID3" {
|
|
return true
|
|
}
|
|
return len(data) >= 2 && data[0] == 0xFF && data[1]&0xE0 == 0xE0
|
|
}
|
|
|
|
// decodeAudio is the format switch of the sinks: RIFF/WAVE (PCM) goes to
|
|
// decodeWAV, MP3 (ID3 tag or frame sync) to decodeMP3. Everything else
|
|
// falls through to decodeWAV, which yields the precise "no RIFF" error.
|
|
func decodeAudio(data []byte) (pcmFormat, []byte, error) {
|
|
if len(data) >= 4 && string(data[0:4]) == "RIFF" {
|
|
return decodeWAV(data)
|
|
}
|
|
if looksLikeMP3(data) {
|
|
return decodeMP3(data)
|
|
}
|
|
return decodeWAV(data)
|
|
}
|