speech.go
2783 bytes
1package adapters
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "sync"
9
10 "git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/stt"
11 "git.theedgeofrage.com/TheEdgeOfRage/kaiwari/internal/tts"
12)
13
14// SpeechInput adapts the stt recorder and ASR client to game.SpeechInput.
15// Begin starts push-to-talk capture; End stops it, uploads the recording for
16// transcription, and returns the raw transcript. Close stops any in-flight
17// capture on shutdown so the recorder child and temp file are not orphaned.
18type SpeechInput struct {
19 Recorder *stt.Recorder
20 ASR *stt.ASRClient
21
22 mu sync.Mutex
23 cur *stt.Recording
24}
25
26func (s *SpeechInput) Begin(ctx context.Context) error {
27 rec, err := s.Recorder.Start(ctx)
28 if err != nil {
29 return fmt.Errorf("mic: %w", err)
30 }
31 s.mu.Lock()
32 s.cur = rec
33 s.mu.Unlock()
34 return nil
35}
36
37func (s *SpeechInput) End(ctx context.Context) (string, error) {
38 s.mu.Lock()
39 rec := s.cur
40 s.cur = nil
41 s.mu.Unlock()
42 if rec == nil {
43 return "", fmt.Errorf("mic: recording not started")
44 }
45 path, err := rec.Stop()
46 if err != nil {
47 return "", fmt.Errorf("mic: %w", err)
48 }
49
50 data, err := os.ReadFile(path)
51 if err != nil {
52 _ = os.Remove(path)
53 return "", fmt.Errorf("mic: read recording %s: %w", path, err)
54 }
55 defer func() { _ = os.Remove(path) }()
56
57 raw, err := s.ASR.TranscribeBytes(ctx, data)
58 if err != nil {
59 return "", err
60 }
61 return raw, nil
62}
63
64// Close stops any in-flight recording and removes its temp file. Called on
65// shutdown so a capture active at quit does not orphan the recorder child or
66// leak its temp WAV. Safe to call when nothing is recording.
67func (s *SpeechInput) Close() {
68 s.mu.Lock()
69 rec := s.cur
70 s.cur = nil
71 s.mu.Unlock()
72 if rec == nil {
73 return
74 }
75 path, err := rec.Stop()
76 if err != nil || path == "" {
77 return
78 }
79 _ = os.Remove(path)
80}
81
82// SpeechOutput adapts the tts client and player to game.SpeechOutput. It
83// synthesizes the NPC reply's kana and plays it without blocking. OnDone, when
84// set, is forwarded to the player and fires once the clip finishes normally.
85type SpeechOutput struct {
86 Client *tts.Client
87 Player *tts.Player
88 OnDone func()
89
90 mu sync.Mutex
91 last []byte
92}
93
94func (s *SpeechOutput) Speak(ctx context.Context, kana, gender string) error {
95 wav, err := s.Client.Speech(ctx, kana, tts.VoiceForGender(gender))
96 if err != nil {
97 return err
98 }
99 s.mu.Lock()
100 cp := make([]byte, len(wav))
101 copy(cp, wav)
102 s.last = cp
103 s.mu.Unlock()
104 return s.Player.Play(wav, s.OnDone)
105}
106
107// Replay plays the last synthesized clip without re-synthesizing it. It returns
108// an error if no clip has been synthesized yet.
109func (s *SpeechOutput) Replay() error {
110 s.mu.Lock()
111 wav := s.last
112 s.mu.Unlock()
113 if len(wav) == 0 {
114 return errors.New("speech: nothing to replay")
115 }
116 return s.Player.Play(wav, s.OnDone)
117}