Parent directory

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}