Parent directory

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}