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>
104 lines
2.6 KiB
Go
104 lines
2.6 KiB
Go
package audio
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ebitengine/oto/v3"
|
|
)
|
|
|
|
// OtoSink plays audio embedded via oto (github.com/ebitengine/oto/v3,
|
|
// pinned to v3.5.0); no external player needed. oto loads the audio drivers
|
|
// at runtime without CGO (purego), which keeps talk cross-compilable.
|
|
type OtoSink struct{}
|
|
|
|
// Name implements Sink.
|
|
func (OtoSink) Name() string { return NameOto }
|
|
|
|
// contextCache keeps one oto context per PCM format: oto supports only ONE
|
|
// context per process ("Creating multiple contexts is NOT supported"), and
|
|
// a context is bound to exactly one format. The CLI plays once per process;
|
|
// the daemon benefits from the cache.
|
|
var contextCache = struct {
|
|
sync.Mutex
|
|
byFormat map[pcmFormat]*otoContext
|
|
}{byFormat: map[pcmFormat]*otoContext{}}
|
|
|
|
type otoContext struct {
|
|
ctx *oto.Context
|
|
ready chan struct{}
|
|
}
|
|
|
|
// otoFormat maps a PCM format to the oto format.
|
|
func (f pcmFormat) otoFormat() oto.Format {
|
|
switch {
|
|
case f.Float:
|
|
return oto.FormatFloat32LE
|
|
case f.BitsPerCoded == 8:
|
|
return oto.FormatUnsignedInt8
|
|
default:
|
|
return oto.FormatSignedInt16LE
|
|
}
|
|
}
|
|
|
|
// contextFor returns the (cached) oto context for a format.
|
|
func contextFor(f pcmFormat) (*otoContext, error) {
|
|
contextCache.Lock()
|
|
defer contextCache.Unlock()
|
|
if c, ok := contextCache.byFormat[f]; ok {
|
|
return c, nil
|
|
}
|
|
op := &oto.NewContextOptions{
|
|
SampleRate: f.SampleRate,
|
|
ChannelCount: f.Channels,
|
|
Format: f.otoFormat(),
|
|
}
|
|
ctx, ready, err := oto.NewContext(op)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("oto: creating context failed (no audio device?): %w", err)
|
|
}
|
|
c := &otoContext{ctx: ctx, ready: ready}
|
|
contextCache.byFormat[f] = c
|
|
return c, nil
|
|
}
|
|
|
|
// Play implements Sink: decodes the data (RIFF/WAVE PCM from osbuiltin or
|
|
// MP3 from the cloud providers, see mp3.go) and plays the samples, blocking.
|
|
// Audio device and format errors are reported clearly.
|
|
func (OtoSink) Play(ctx context.Context, data []byte) error {
|
|
f, pcm, err := decodeAudio(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c, err := contextFor(f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
select {
|
|
case <-c.ready:
|
|
if err := c.ctx.Err(); err != nil {
|
|
return fmt.Errorf("oto: initialization failed: %w", err)
|
|
}
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
player := c.ctx.NewPlayer(bytes.NewReader(pcm))
|
|
defer player.Close()
|
|
player.Play()
|
|
for player.IsPlaying() {
|
|
if err := ctx.Err(); err != nil {
|
|
player.Close()
|
|
return err
|
|
}
|
|
if err := player.Err(); err != nil {
|
|
return fmt.Errorf("oto: playback failed: %w", err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
return player.Err()
|
|
}
|