1import {
2 VOICE_RECORDING_LIMIT_SECONDS,
3 VoiceInputController,
4 voiceInputBlocksSubmission,
5 type VoiceDraftSnapshot,
6 type VoiceInputControllerDependencies,
7 type VoiceInputPhase,
8 type VoiceInputState,
9} from "@t3tools/client-runtime/voice-input";
10
11import { normalizeVoiceInputDecibels, VOICE_WAVEFORM_SAMPLE_COUNT } from "./voiceInputMetering";
12
13const VOICE_METERING_INTERVAL_MS = 80;
14const IDLE_STATE: VoiceInputState = { phase: "idle", error: null, errorAction: null };
15const SILENT_LEVELS: ReadonlyArray<number> = Array<number>(VOICE_WAVEFORM_SAMPLE_COUNT).fill(0);
16
17export type VoiceInputTarget = {
18 readonly ownerKey: string;
19 readonly label?: string;
20 readonly readDraft: () => VoiceDraftSnapshot | null;
21 readonly commitDraft: VoiceInputControllerDependencies["commitDraft"];
22};
23
24export type VoiceInputSnapshot = {
25 readonly state: VoiceInputState;
26 readonly ownerKey: string | null;
27 readonly label: string | null;
28 readonly elapsedSeconds: number;
29 readonly focusedOwners: ReadonlySet<string>;
30};
31
32export type VoiceRecordingSample = {
33 readonly durationMillis: number;
34 readonly metering: number | undefined;
35};
36
37export type VoiceInputSessionDependencies = Omit<
38 VoiceInputControllerDependencies,
39 "readDraft" | "commitDraft" | "onStateChange"
40> & {
41 /** Reads the live recorder, or null when it is not capturing. */
42 readonly sampleRecording: () => VoiceRecordingSample | null;
43 /** Levels change every metering tick, so they bypass React state. */
44 readonly publishAudioLevels: (levels: ReadonlyArray<number>) => void;
45 /** Holds the screen awake while recording; returns the release. */
46 readonly keepAwake: () => () => void;
47};
48
49export function createVoiceInputTarget(
50 ownerKey: string,
51 readText: () => string | null,
52 commitDraft: VoiceInputTarget["commitDraft"],
53 selection: VoiceDraftSnapshot["selection"],
54): VoiceInputTarget {
55 return {
56 ownerKey,
57 readDraft: () => {
58 const text = readText();
59 return text === null ? null : { ownerKey, text, selection };
60 },
61 commitDraft,
62 };
63}
64
65/**
66 * The app's single voice input: it owns the controller, the recording-only
67 * side effects (keep-awake, metering) and publishes what the UI renders.
68 */
69export class VoiceInputSession {
70 readonly controller: VoiceInputController;
71 private readonly dependencies: VoiceInputSessionDependencies;
72 private target: VoiceInputTarget | null = null;
73 private startText: string | null = null;
74 private snapshot: VoiceInputSnapshot = {
75 state: IDLE_STATE,
76 ownerKey: null,
77 label: null,
78 elapsedSeconds: 0,
79 focusedOwners: new Set(),
80 };
81 private readonly listeners = new Set<() => void>();
82 private audioLevels = SILENT_LEVELS;
83 private releaseKeepAwake: (() => void) | null = null;
84 private meteringInterval: ReturnType<typeof setInterval> | null = null;
85
86 constructor(dependencies: VoiceInputSessionDependencies) {
87 this.dependencies = dependencies;
88 this.controller = new VoiceInputController({
89 ...dependencies,
90 onStateChange: (state) => this.handleStateChange(state),
91 // The target's selection is fixed at start, so a draft changed since refuses the transcript.
92 readDraft: () => {
93 const draft = this.target?.readDraft() ?? null;
94 return draft?.text === this.startText ? draft : null;
95 },
96 commitDraft: (text, selection) => this.target?.commitDraft(text, selection),
97 });
98 }
99
100 get ownerKey(): string | null {
101 return this.target?.ownerKey ?? null;
102 }
103
104 get label(): string | null {
105 return this.target?.label ?? null;
106 }
107
108 readonly subscribe = (listener: () => void): (() => void) => {
109 this.listeners.add(listener);
110 return () => this.listeners.delete(listener);
111 };
112
113 readonly getSnapshot = (): VoiceInputSnapshot => this.snapshot;
114
115 setOwnerFocused(ownerKey: string, focused: boolean): void {
116 if (this.snapshot.focusedOwners.has(ownerKey) === focused) return;
117 const focusedOwners = new Set(this.snapshot.focusedOwners);
118 if (focused) focusedOwners.add(ownerKey);
119 else focusedOwners.delete(ownerKey);
120 this.publish({ ...this.snapshot, focusedOwners });
121 }
122
123 cancel(ownerKey: string | null): void {
124 if (ownerKey !== null && this.ownerKey === ownerKey) this.controller.cancel();
125 }
126
127 retry(): Promise<void> {
128 return this.target ? this.start(this.target) : Promise.resolve();
129 }
130
131 start(target: VoiceInputTarget): Promise<void> {
132 if (voiceInputBlocksSubmission(this.controller.currentState)) return Promise.resolve();
133 this.target = target;
134 this.startText = target.readDraft()?.text ?? null;
135 return this.controller.start();
136 }
137
138 dispose(): void {
139 this.controller.dispose();
140 this.stopRecordingActivity();
141 this.listeners.clear();
142 }
143
144 private handleStateChange(state: VoiceInputState): void {
145 const previousPhase = this.snapshot.state.phase;
146 let elapsedSeconds = this.snapshot.elapsedSeconds;
147 if (state.phase !== previousPhase) {
148 if (previousPhase === "recording") this.stopRecordingActivity();
149 if (capturesAudio(state.phase)) {
150 if (this.audioLevels.some((level) => level !== 0)) this.setAudioLevels(SILENT_LEVELS);
151 elapsedSeconds = 0;
152 }
153 }
154 this.publish({
155 ...this.snapshot,
156 state,
157 ownerKey: this.ownerKey,
158 label: this.label,
159 elapsedSeconds,
160 });
161 if (state.phase === "recording" && previousPhase !== "recording") {
162 this.startRecordingActivity();
163 }
164 }
165
166 private startRecordingActivity(): void {
167 this.releaseKeepAwake = this.dependencies.keepAwake();
168 this.sampleRecording();
169 this.meteringInterval = setInterval(() => this.sampleRecording(), VOICE_METERING_INTERVAL_MS);
170 }
171
172 private stopRecordingActivity(): void {
173 if (this.meteringInterval !== null) clearInterval(this.meteringInterval);
174 this.meteringInterval = null;
175 this.releaseKeepAwake?.();
176 this.releaseKeepAwake = null;
177 }
178
179 private sampleRecording(): void {
180 if (this.controller.currentState.phase !== "recording") return;
181 const sample = this.dependencies.sampleRecording();
182 if (!sample) return;
183
184 const level = normalizeVoiceInputDecibels(sample.metering);
185 if (level !== 0 || this.audioLevels.some((sample) => sample !== 0)) {
186 this.setAudioLevels([...this.audioLevels.slice(1), level]);
187 }
188
189 const elapsedSeconds = Math.min(
190 VOICE_RECORDING_LIMIT_SECONDS,
191 Math.max(0, Math.floor(sample.durationMillis / 1_000)),
192 );
193 if (elapsedSeconds !== this.snapshot.elapsedSeconds) {
194 this.publish({ ...this.snapshot, elapsedSeconds });
195 }
196 }
197
198 private setAudioLevels(levels: ReadonlyArray<number>): void {
199 this.audioLevels = levels;
200 this.dependencies.publishAudioLevels(levels);
201 }
202
203 private publish(snapshot: VoiceInputSnapshot): void {
204 this.snapshot = snapshot;
205 for (const listener of this.listeners) listener();
206 }
207}
208
209function capturesAudio(phase: VoiceInputPhase): boolean {
210 return phase === "preparing" || phase === "recording";
211}