player.go
2556 bytes
1package tts
2
3import (
4 "bytes"
5 "context"
6 "sync"
7 "time"
8
9 "github.com/ebitengine/oto/v3"
10)
11
12// Player plays synthesized WAV bytes with oto/v3. The audio context is created
13// once, lazily, at the first clip's sample rate, channel count, and format; all
14// later clips are played through that same context (oto supports a single
15// context). Play validates the WAV synchronously and returns any error from
16// that check; device work happens off the calling goroutine so the TUI event
17// loop is never blocked. A new Play interrupts an in-flight playback.
18//
19// Each Play takes an onDone callback that fires exactly once when that clip
20// finishes playing normally. An interrupted clip (superseded by a newer Play)
21// never fires onDone, so callers can distinguish "played to the end" from
22// "cut off".
23type Player struct {
24 mu sync.Mutex
25 ctx *oto.Context
26 stopCur context.CancelFunc
27}
28
29func NewPlayer() *Player { return &Player{} }
30
31// Play plays wav without blocking. It returns an error only if the WAV is
32// invalid; audio-device failures are not surfaced (the clip simply does not
33// play). Starting a new playback interrupts any in-flight one. onDone may be
34// nil; when set it fires once the clip finishes normally, never on interrupt.
35// The slice may be used briefly after Play returns, so do not reuse it.
36func (p *Player) Play(wav []byte, onDone func()) error {
37 spec, err := ParseWAV(wav)
38 if err != nil {
39 return err
40 }
41 ctx, cancel := context.WithCancel(context.Background())
42 p.mu.Lock()
43 prev := p.stopCur
44 p.stopCur = cancel
45 p.mu.Unlock()
46 if prev != nil {
47 prev() // interrupt any in-flight playback
48 }
49 go p.play(ctx, spec, onDone)
50 return nil
51}
52
53func (p *Player) play(ctx context.Context, spec *WAVSpec, onDone func()) {
54 data := make([]byte, len(spec.Data))
55 copy(data, spec.Data)
56
57 finish := func() {
58 if onDone != nil {
59 onDone()
60 }
61 }
62
63 p.mu.Lock()
64 if p.ctx == nil {
65 opts := &oto.NewContextOptions{
66 SampleRate: spec.SampleRate,
67 ChannelCount: spec.Channels,
68 Format: formatForBits(spec.BitsPerSample),
69 }
70 ctx0, ready, err := oto.NewContext(opts)
71 if err != nil {
72 p.mu.Unlock()
73 return
74 }
75 <-ready
76 p.ctx = ctx0
77 }
78 octx := p.ctx
79 p.mu.Unlock()
80
81 pl := octx.NewPlayer(bytes.NewReader(data))
82 pl.Play()
83 for pl.IsPlaying() {
84 select {
85 case <-ctx.Done():
86 pl.PauseAndStopReading()
87 return
88 case <-time.After(20 * time.Millisecond):
89 }
90 }
91 finish()
92}
93
94func formatForBits(bits int) oto.Format {
95 if bits == 8 {
96 return oto.FormatUnsignedInt8
97 }
98 return oto.FormatSignedInt16LE
99}