Clicking Cancel reached the daemon and the queue cancelled the item, but the sound played on to the end: oto v3's Player.Close only relies on the garbage collector's finalizer, so the player stayed in the mix. Pause takes it out immediately; only the device buffer (about 100 ms) drains. Same fix covers talk cancel and daemon shutdown. Direct speech via spd-say: killing the waiting client did not stop speech-dispatcher either; on cancel it now sends `spd-say -S`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
109 lines
2.8 KiB
Go
109 lines
2.8 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 {
|
|
// Cancel: stop the sound NOW. oto's Close is a no-op that
|
|
// relies on the garbage collector's finalizer, so a cancelled
|
|
// announcement would play on to its end; Pause takes the
|
|
// player out of the mix immediately (only the device buffer,
|
|
// about 100 ms, drains).
|
|
player.Pause()
|
|
return err
|
|
}
|
|
if err := player.Err(); err != nil {
|
|
return fmt.Errorf("oto: playback failed: %w", err)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
return player.Err()
|
|
}
|