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() }