use-voice-control 0.1.37 → 0.1.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -3
- package/dist/client/index.d.ts +8 -0
- package/dist/client/live-transcriber.d.ts +50 -0
- package/dist/client/read-aloud.d.ts +65 -0
- package/dist/client.js +301 -0
- package/dist/client.js.map +1 -0
- package/dist/react/SpokenPhraseOverlay.d.ts +18 -0
- package/dist/react/index.d.ts +8 -0
- package/dist/react/useLiveTranscription.d.ts +19 -0
- package/dist/react/useReadAloud.d.ts +22 -0
- package/dist/react.js +212 -0
- package/dist/react.js.map +1 -0
- package/package.json +24 -10
- package/speech/api-client.ts +7 -0
- package/speech/client/index.ts +21 -0
- package/speech/client/live-transcriber.ts +216 -0
- package/speech/client/read-aloud.ts +305 -0
- package/speech/core/KokoroTTS.js +7 -0
- package/speech/core/kokoro.js +8 -0
- package/speech/legacy/conversation.js +7 -0
- package/speech/legacy/stt.js +7 -0
- package/speech/legacy/tts.js +7 -0
- package/speech/legacy/worker.js +7 -0
- package/speech/react/SpokenPhraseOverlay.tsx +126 -0
- package/speech/react/index.ts +27 -0
- package/speech/react/useLiveTranscription.ts +142 -0
- package/speech/react/useReadAloud.ts +137 -0
- package/speech/ui/AudioPlayer.js +6 -0
- package/speech/ui/ui.js +7 -0
- package/speech/ui/voice-selector.js +3 -0
- package/speech/ui/voices.js +7 -0
- package/speech/utils/audio-utils.js +7 -0
- package/speech/utils/phonemize.js +7 -0
- package/speech/utils/semantic-split.d.ts +10 -0
- package/speech/utils/semantic-split.js +7 -0
- package/speech/utils/sentence-detector.d.ts +13 -0
- package/speech/utils/sentence-detector.js +5 -1
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Browser-side "read aloud" playback engine.
|
|
3
|
+
*
|
|
4
|
+
* `ReadAloudController` takes a block of text, chunks it along sentence/paragraph
|
|
5
|
+
* boundaries with `splitTextSmart`, synthesizes each chunk (Kokoro by default, via
|
|
6
|
+
* the package's `/api/speech/tts` route) and plays the chunks back-to-back while
|
|
7
|
+
* pre-fetching the next one, so playback starts after the first short chunk rather
|
|
8
|
+
* than after the whole document. Pause/resume/stop are supported throughout, and
|
|
9
|
+
* each chunk is reported to `onChunk` so callers can highlight what is being spoken.
|
|
10
|
+
*
|
|
11
|
+
* When no TTS endpoint is reachable — a host app that has not mounted the route, or
|
|
12
|
+
* an offline build — the controller falls back to the browser's built-in
|
|
13
|
+
* `speechSynthesis` rather than failing, so the feature still works everywhere.
|
|
14
|
+
*/
|
|
15
|
+
import type { TTSProvider } from "../types/types";
|
|
16
|
+
import { splitTextSmart } from "../utils/semantic-split.js";
|
|
17
|
+
|
|
18
|
+
export type ReadAloudState = "idle" | "loading" | "speaking" | "paused";
|
|
19
|
+
|
|
20
|
+
export interface ReadAloudChunk {
|
|
21
|
+
/** The text of the chunk currently being spoken. */
|
|
22
|
+
text: string;
|
|
23
|
+
/** Zero-based position of this chunk in the queue. */
|
|
24
|
+
index: number;
|
|
25
|
+
/** Total number of chunks queued for this utterance. */
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Turns a chunk of text into playable audio. Return `null` to defer to `speechSynthesis`. */
|
|
30
|
+
export type SynthesizeFn = (
|
|
31
|
+
text: string,
|
|
32
|
+
signal: AbortSignal
|
|
33
|
+
) => Promise<Blob | null>;
|
|
34
|
+
|
|
35
|
+
export interface ReadAloudOptions {
|
|
36
|
+
/** TTS provider passed to the endpoint. Default `kokoro`. */
|
|
37
|
+
provider?: TTSProvider;
|
|
38
|
+
/** Provider-specific voice id. Default `af_heart`. */
|
|
39
|
+
voice?: string;
|
|
40
|
+
/** HTTP route that synthesizes text. Default `/api/speech/tts`. */
|
|
41
|
+
endpoint?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Target chunk size in characters. Smaller starts talking sooner but makes the
|
|
44
|
+
* seams between chunks more audible. Default 240.
|
|
45
|
+
*/
|
|
46
|
+
maxChunkLength?: number;
|
|
47
|
+
/** Override synthesis entirely (tests, a bring-your-own-TTS host app). */
|
|
48
|
+
synthesize?: SynthesizeFn;
|
|
49
|
+
/** Called as each chunk starts playing. */
|
|
50
|
+
onChunk?: (chunk: ReadAloudChunk) => void;
|
|
51
|
+
/** Called on every state transition. */
|
|
52
|
+
onStateChange?: (state: ReadAloudState) => void;
|
|
53
|
+
/** Called when playback ends, either by running out of chunks or by `stop()`. */
|
|
54
|
+
onEnd?: (reason: "finished" | "stopped") => void;
|
|
55
|
+
/** Called when synthesis or playback fails irrecoverably. */
|
|
56
|
+
onError?: (error: Error) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_ENDPOINT = "/api/speech/tts";
|
|
60
|
+
const DEFAULT_VOICE = "af_heart";
|
|
61
|
+
const DEFAULT_MAX_CHUNK = 240;
|
|
62
|
+
|
|
63
|
+
/** Resolves once `signal` aborts. Used to race playback against `stop()`. */
|
|
64
|
+
function abortPromise(signal: AbortSignal): Promise<void> {
|
|
65
|
+
if (signal.aborted) return Promise.resolve();
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class ReadAloudController {
|
|
72
|
+
private options: ReadAloudOptions;
|
|
73
|
+
private state: ReadAloudState = "idle";
|
|
74
|
+
private abortController: AbortController | null = null;
|
|
75
|
+
private audio: HTMLAudioElement | null = null;
|
|
76
|
+
private objectUrl: string | null = null;
|
|
77
|
+
/** Set once the endpoint has proved unreachable, so later chunks skip the retry. */
|
|
78
|
+
private endpointUnavailable = false;
|
|
79
|
+
|
|
80
|
+
constructor(options: ReadAloudOptions = {}) {
|
|
81
|
+
this.options = options;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Replace the options (voice, callbacks, …) without discarding playback state. */
|
|
85
|
+
setOptions(options: ReadAloudOptions): void {
|
|
86
|
+
this.options = options;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getState(): ReadAloudState {
|
|
90
|
+
return this.state;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
isActive(): boolean {
|
|
94
|
+
return this.state === "speaking" || this.state === "paused" || this.state === "loading";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Speak `text`, cancelling anything already playing. Resolves when playback
|
|
99
|
+
* finishes or is stopped — it never rejects; failures go to `onError`.
|
|
100
|
+
*/
|
|
101
|
+
async speak(text: string): Promise<void> {
|
|
102
|
+
this.stop();
|
|
103
|
+
|
|
104
|
+
const maxChunkLength = this.options.maxChunkLength ?? DEFAULT_MAX_CHUNK;
|
|
105
|
+
const chunks = splitTextSmart(text ?? "", maxChunkLength)
|
|
106
|
+
.map((chunk) => chunk.trim())
|
|
107
|
+
.filter((chunk) => chunk.length > 0);
|
|
108
|
+
|
|
109
|
+
if (chunks.length === 0) return;
|
|
110
|
+
|
|
111
|
+
const abortController = new AbortController();
|
|
112
|
+
this.abortController = abortController;
|
|
113
|
+
const { signal } = abortController;
|
|
114
|
+
this.setState("loading");
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Kick off the first chunk, then always keep exactly one chunk in flight
|
|
118
|
+
// ahead of the one playing.
|
|
119
|
+
let upcoming = this.synthesize(chunks[0], signal);
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < chunks.length; index += 1) {
|
|
122
|
+
const current = upcoming;
|
|
123
|
+
upcoming =
|
|
124
|
+
index + 1 < chunks.length
|
|
125
|
+
? this.synthesize(chunks[index + 1], signal)
|
|
126
|
+
: Promise.resolve(null);
|
|
127
|
+
|
|
128
|
+
const blob = await current;
|
|
129
|
+
if (signal.aborted) return;
|
|
130
|
+
|
|
131
|
+
// Announce the state before the chunk so a listener reacting to
|
|
132
|
+
// `onChunk` sees the controller already speaking.
|
|
133
|
+
this.setState("speaking");
|
|
134
|
+
this.options.onChunk?.({ text: chunks[index], index, total: chunks.length });
|
|
135
|
+
if (signal.aborted) return;
|
|
136
|
+
await this.playChunk(blob, chunks[index], signal);
|
|
137
|
+
if (signal.aborted) return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.cleanup();
|
|
141
|
+
this.setState("idle");
|
|
142
|
+
this.options.onEnd?.("finished");
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (signal.aborted) return;
|
|
145
|
+
this.cleanup();
|
|
146
|
+
this.setState("idle");
|
|
147
|
+
this.options.onError?.(
|
|
148
|
+
error instanceof Error ? error : new Error(String(error))
|
|
149
|
+
);
|
|
150
|
+
this.options.onEnd?.("stopped");
|
|
151
|
+
} finally {
|
|
152
|
+
if (this.abortController === abortController) {
|
|
153
|
+
this.abortController = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
pause(): void {
|
|
159
|
+
if (this.state !== "speaking") return;
|
|
160
|
+
if (this.audio) {
|
|
161
|
+
this.audio.pause();
|
|
162
|
+
} else if (typeof speechSynthesis !== "undefined") {
|
|
163
|
+
speechSynthesis.pause();
|
|
164
|
+
}
|
|
165
|
+
this.setState("paused");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
resume(): void {
|
|
169
|
+
if (this.state !== "paused") return;
|
|
170
|
+
if (this.audio) {
|
|
171
|
+
void this.audio.play().catch(() => undefined);
|
|
172
|
+
} else if (typeof speechSynthesis !== "undefined") {
|
|
173
|
+
speechSynthesis.resume();
|
|
174
|
+
}
|
|
175
|
+
this.setState("speaking");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Stop playback and drop any queued chunks. Safe to call when already idle. */
|
|
179
|
+
stop(): void {
|
|
180
|
+
const wasActive = this.isActive();
|
|
181
|
+
this.abortController?.abort();
|
|
182
|
+
this.abortController = null;
|
|
183
|
+
this.cleanup();
|
|
184
|
+
if (wasActive) {
|
|
185
|
+
this.setState("idle");
|
|
186
|
+
this.options.onEnd?.("stopped");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private setState(state: ReadAloudState): void {
|
|
191
|
+
if (this.state === state) return;
|
|
192
|
+
this.state = state;
|
|
193
|
+
this.options.onStateChange?.(state);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private cleanup(): void {
|
|
197
|
+
if (this.audio) {
|
|
198
|
+
this.audio.pause();
|
|
199
|
+
this.audio.src = "";
|
|
200
|
+
this.audio = null;
|
|
201
|
+
}
|
|
202
|
+
if (this.objectUrl) {
|
|
203
|
+
URL.revokeObjectURL(this.objectUrl);
|
|
204
|
+
this.objectUrl = null;
|
|
205
|
+
}
|
|
206
|
+
if (typeof speechSynthesis !== "undefined") {
|
|
207
|
+
speechSynthesis.cancel();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private synthesize(text: string, signal: AbortSignal): Promise<Blob | null> {
|
|
212
|
+
const promise = this.options.synthesize
|
|
213
|
+
? this.options.synthesize(text, signal)
|
|
214
|
+
: this.fetchAudio(text, signal);
|
|
215
|
+
// Pre-fetched chunks are awaited a beat later; mark them handled now so a
|
|
216
|
+
// stop() mid-flight does not surface as an unhandled rejection.
|
|
217
|
+
promise.catch(() => undefined);
|
|
218
|
+
return promise;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async fetchAudio(text: string, signal: AbortSignal): Promise<Blob | null> {
|
|
222
|
+
if (this.endpointUnavailable) return null;
|
|
223
|
+
|
|
224
|
+
const endpoint = this.options.endpoint ?? DEFAULT_ENDPOINT;
|
|
225
|
+
try {
|
|
226
|
+
const response = await fetch(endpoint, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "Content-Type": "application/json" },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
text,
|
|
231
|
+
provider: this.options.provider ?? "kokoro",
|
|
232
|
+
voice: this.options.voice ?? DEFAULT_VOICE,
|
|
233
|
+
}),
|
|
234
|
+
signal,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (!response.ok) throw new Error(`TTS failed: ${response.status}`);
|
|
238
|
+
|
|
239
|
+
const contentType = response.headers.get("Content-Type") || "audio/wav";
|
|
240
|
+
return new Blob([await response.arrayBuffer()], { type: contentType });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (signal.aborted) throw error;
|
|
243
|
+
// No usable endpoint in this host app — remember it and let every
|
|
244
|
+
// remaining chunk go straight to the browser's own synthesizer.
|
|
245
|
+
this.endpointUnavailable = true;
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private playChunk(
|
|
251
|
+
blob: Blob | null,
|
|
252
|
+
text: string,
|
|
253
|
+
signal: AbortSignal
|
|
254
|
+
): Promise<void> {
|
|
255
|
+
return blob
|
|
256
|
+
? this.playAudioBlob(blob, signal)
|
|
257
|
+
: this.playWithSpeechSynthesis(text, signal);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async playAudioBlob(blob: Blob, signal: AbortSignal): Promise<void> {
|
|
261
|
+
const url = URL.createObjectURL(blob);
|
|
262
|
+
const audio = new Audio(url);
|
|
263
|
+
this.audio = audio;
|
|
264
|
+
this.objectUrl = url;
|
|
265
|
+
|
|
266
|
+
const finished = new Promise<void>((resolve, reject) => {
|
|
267
|
+
audio.addEventListener("ended", () => resolve(), { once: true });
|
|
268
|
+
audio.addEventListener(
|
|
269
|
+
"error",
|
|
270
|
+
() => reject(new Error("Audio playback failed")),
|
|
271
|
+
{ once: true }
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await audio.play();
|
|
277
|
+
await Promise.race([finished, abortPromise(signal)]);
|
|
278
|
+
} finally {
|
|
279
|
+
if (this.audio === audio) this.audio = null;
|
|
280
|
+
if (this.objectUrl === url) this.objectUrl = null;
|
|
281
|
+
audio.pause();
|
|
282
|
+
URL.revokeObjectURL(url);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async playWithSpeechSynthesis(
|
|
287
|
+
text: string,
|
|
288
|
+
signal: AbortSignal
|
|
289
|
+
): Promise<void> {
|
|
290
|
+
if (typeof speechSynthesis === "undefined" || typeof SpeechSynthesisUtterance === "undefined") {
|
|
291
|
+
throw new Error("No speech synthesis available in this browser");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const utterance = new SpeechSynthesisUtterance(text);
|
|
295
|
+
const finished = new Promise<void>((resolve, reject) => {
|
|
296
|
+
utterance.onend = () => resolve();
|
|
297
|
+
// `cancel()` fires an error event; a stop is not a failure.
|
|
298
|
+
utterance.onerror = () =>
|
|
299
|
+
signal.aborted ? resolve() : reject(new Error("Speech synthesis failed"));
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
speechSynthesis.speak(utterance);
|
|
303
|
+
await Promise.race([finished, abortPromise(signal)]);
|
|
304
|
+
}
|
|
305
|
+
}
|
package/speech/core/KokoroTTS.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Standalone `KokoroTTS` class generating audio from text via a Kokoro model loaded from Hugging Face, using transformers.js pulled from a CDN URL.
|
|
3
|
+
*
|
|
4
|
+
* Near-identical unused duplicate of `core/kokoro.js` (same CDN-based implementation,
|
|
5
|
+
* missing that file's commented-out package-import alternative); no other file in this
|
|
6
|
+
* package imports `KokoroTTS.js` directly.
|
|
7
|
+
*/
|
|
1
8
|
import { StyleTextToSpeech2Model, AutoTokenizer, Tensor, RawAudio } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js";
|
|
2
9
|
|
|
3
10
|
import { phonemize } from "./phonemize.js";
|
package/speech/core/kokoro.js
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Browser/worker `KokoroTTS` class that generates audio from text using a Kokoro model loaded via Hugging Face transformers.
|
|
3
|
+
*
|
|
4
|
+
* Loads `@huggingface/transformers` from a jsdelivr CDN URL (rather than the npm package)
|
|
5
|
+
* so it can run inside the Web Worker in `legacy/worker.js` without a bundler resolving the
|
|
6
|
+
* dependency. Kept alongside the near-duplicate `KokoroTTS.js` in this directory; this is the
|
|
7
|
+
* file actually imported by `legacy/worker.js`.
|
|
8
|
+
*/
|
|
1
9
|
import { StyleTextToSpeech2Model, AutoTokenizer, Tensor, RawAudio } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js";
|
|
2
10
|
//import { StyleTextToSpeech2Model, AutoTokenizer, Tensor, RawAudio } from "@huggingface/transformers";
|
|
3
11
|
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Legacy `Conversation` class driving the full voice loop: record speech, transcribe it, stream an LLM chat completion, and speak the response sentence-by-sentence.
|
|
3
|
+
*
|
|
4
|
+
* Pre-refactor, DOM-coupled implementation (queries elements like `#serverUrl` and
|
|
5
|
+
* `#systemPrompt` directly) that wires together `SpeechToText`, the streaming chat fetch,
|
|
6
|
+
* `processStreamingText` for incremental sentence detection, and `textToSpeech` playback.
|
|
7
|
+
*/
|
|
1
8
|
import { SpeechToText } from "./stt.js";
|
|
2
9
|
import { textToSpeech, ttsModelReadyPromise } from "./tts.js";
|
|
3
10
|
import { processStreamingText } from "./sentence-detector.js";
|
package/speech/legacy/stt.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Legacy `SpeechToText` class performing in-browser speech recognition via a Hugging Face transformers.js ASR pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Detects WebGPU support to pick device/dtype, records microphone audio with
|
|
5
|
+
* `MediaRecorder`, converts it to WAV (with a gain-boosted retry if transcription comes
|
|
6
|
+
* back empty), and transcribes it with the `onnx-community/moonshine-base-ONNX` model.
|
|
7
|
+
*/
|
|
1
8
|
import { pipeline } from "https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.5.1/dist/transformers.min.js";
|
|
2
9
|
|
|
3
10
|
import { convertAudioBufferToWav, resampleAudio, applyAudioGain } from "./audio-utils.js";
|
package/speech/legacy/tts.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Legacy `textToSpeech` entry point that offloads Kokoro TTS generation to a Web Worker and streams the resulting audio to an `AudioPlayer`.
|
|
3
|
+
*
|
|
4
|
+
* Exposes `ttsModelReadyPromise` (resolved once the worker reports its model is loaded) and
|
|
5
|
+
* `textToSpeech(text, voice)`, which strips Markdown formatting before posting the request
|
|
6
|
+
* to `worker.js`.
|
|
7
|
+
*/
|
|
1
8
|
import { AudioPlayer } from "./AudioPlayer.js";
|
|
2
9
|
|
|
3
10
|
const my_worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
|
package/speech/legacy/worker.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Web Worker (legacy) that loads the Kokoro TTS model off the main thread and generates audio for queued text chunks, streaming each result back via `postMessage`.
|
|
3
|
+
*
|
|
4
|
+
* Detects WebGPU, loads `KokoroTTS` from `../core/kokoro.js`, splits incoming text into
|
|
5
|
+
* chunks with `splitTextSmart`, and processes them one at a time through a simple queue,
|
|
6
|
+
* transferring each generated audio buffer back to the main thread.
|
|
7
|
+
*/
|
|
1
8
|
import { KokoroTTS } from "./kokoro.js";
|
|
2
9
|
import { splitTextSmart } from "./semantic-split.js";
|
|
3
10
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Full-screen display of the phrase that was just spoken.
|
|
3
|
+
*
|
|
4
|
+
* While dictating, the words land in whatever input has focus — which is easy to
|
|
5
|
+
* lose track of when you are looking away from the screen. This overlay echoes the
|
|
6
|
+
* latest phrase in large type at the centre of the viewport and fades out two
|
|
7
|
+
* seconds after the last update, so a glance confirms you were heard correctly.
|
|
8
|
+
*
|
|
9
|
+
* It renders into `document.body` through a portal with pointer events disabled, so
|
|
10
|
+
* it never intercepts clicks or gets clipped by the host app's layout, and it is
|
|
11
|
+
* styled inline so it looks the same in every app that mounts it regardless of the
|
|
12
|
+
* CSS framework in play.
|
|
13
|
+
*/
|
|
14
|
+
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
|
15
|
+
import { createPortal } from "react-dom";
|
|
16
|
+
|
|
17
|
+
export interface SpokenPhraseOverlayProps {
|
|
18
|
+
/** The words to display. An empty string hides the overlay. */
|
|
19
|
+
phrase: string;
|
|
20
|
+
/**
|
|
21
|
+
* Bump this whenever `phrase` is refreshed to restart the hide timer, including
|
|
22
|
+
* when the same words are repeated. `useLiveTranscription` supplies it.
|
|
23
|
+
*/
|
|
24
|
+
phraseId?: number;
|
|
25
|
+
/** How long the phrase stays up after the last update. Default 2000ms. */
|
|
26
|
+
durationMs?: number;
|
|
27
|
+
/** Set false to suppress the overlay entirely (e.g. once dictation stops). */
|
|
28
|
+
visible?: boolean;
|
|
29
|
+
/** Stacking order. Default 2147483000, above typical app chrome and modals. */
|
|
30
|
+
zIndex?: number;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HIDE_AFTER_MS = 2000;
|
|
35
|
+
|
|
36
|
+
export function SpokenPhraseOverlay({
|
|
37
|
+
phrase,
|
|
38
|
+
phraseId,
|
|
39
|
+
durationMs = HIDE_AFTER_MS,
|
|
40
|
+
visible = true,
|
|
41
|
+
zIndex = 2147483000,
|
|
42
|
+
className,
|
|
43
|
+
}: SpokenPhraseOverlayProps) {
|
|
44
|
+
const [mounted, setMounted] = useState(false);
|
|
45
|
+
const [shown, setShown] = useState(false);
|
|
46
|
+
// Held separately from `phrase` so the text stays put while fading out rather
|
|
47
|
+
// than blanking the moment the transcript clears.
|
|
48
|
+
const [displayed, setDisplayed] = useState("");
|
|
49
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
50
|
+
|
|
51
|
+
useEffect(() => setMounted(true), []);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const text = phrase.trim();
|
|
55
|
+
if (!visible || !text) {
|
|
56
|
+
if (!visible) setShown(false);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
setDisplayed(text);
|
|
61
|
+
setShown(true);
|
|
62
|
+
|
|
63
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
64
|
+
timerRef.current = setTimeout(() => setShown(false), durationMs);
|
|
65
|
+
|
|
66
|
+
return () => {
|
|
67
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
68
|
+
};
|
|
69
|
+
}, [phrase, phraseId, visible, durationMs]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
return () => {
|
|
73
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
if (!mounted || !displayed) return null;
|
|
78
|
+
|
|
79
|
+
const containerStyle: CSSProperties = {
|
|
80
|
+
position: "fixed",
|
|
81
|
+
inset: 0,
|
|
82
|
+
display: "flex",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
justifyContent: "center",
|
|
85
|
+
pointerEvents: "none",
|
|
86
|
+
padding: "6vw",
|
|
87
|
+
zIndex,
|
|
88
|
+
opacity: shown ? 1 : 0,
|
|
89
|
+
transition: "opacity 320ms ease-out",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const textStyle: CSSProperties = {
|
|
93
|
+
maxWidth: "min(1100px, 90vw)",
|
|
94
|
+
textAlign: "center",
|
|
95
|
+
// Scales with the viewport so a short phrase fills the screen on a laptop
|
|
96
|
+
// and still fits on a phone.
|
|
97
|
+
fontSize: "clamp(2rem, 7vw, 6rem)",
|
|
98
|
+
fontWeight: 700,
|
|
99
|
+
lineHeight: 1.1,
|
|
100
|
+
letterSpacing: "-0.02em",
|
|
101
|
+
color: "#ffffff",
|
|
102
|
+
// A dark pill keeps the words readable over light and dark pages alike.
|
|
103
|
+
background: "rgba(15, 23, 42, 0.82)",
|
|
104
|
+
borderRadius: "1.5rem",
|
|
105
|
+
padding: "0.6em 0.8em",
|
|
106
|
+
boxShadow: "0 25px 80px rgba(0, 0, 0, 0.45)",
|
|
107
|
+
backdropFilter: "blur(8px)",
|
|
108
|
+
transform: shown ? "scale(1)" : "scale(0.96)",
|
|
109
|
+
transition: "transform 320ms ease-out",
|
|
110
|
+
overflowWrap: "anywhere",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return createPortal(
|
|
114
|
+
<div
|
|
115
|
+
aria-hidden="true"
|
|
116
|
+
className={className}
|
|
117
|
+
data-spoken-phrase-overlay=""
|
|
118
|
+
style={containerStyle}
|
|
119
|
+
>
|
|
120
|
+
<div style={textStyle}>{displayed}</div>
|
|
121
|
+
</div>,
|
|
122
|
+
document.body
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export default SpokenPhraseOverlay;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Public entry point for the React layer: the read-aloud and live
|
|
3
|
+
* dictation hooks, plus the centred overlay that echoes the phrase just spoken.
|
|
4
|
+
*/
|
|
5
|
+
export { useReadAloud, type UseReadAloudOptions, type UseReadAloudReturn } from "./useReadAloud";
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
useLiveTranscription,
|
|
9
|
+
type UseLiveTranscriptionOptions,
|
|
10
|
+
type UseLiveTranscriptionReturn,
|
|
11
|
+
} from "./useLiveTranscription";
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
SpokenPhraseOverlay,
|
|
15
|
+
type SpokenPhraseOverlayProps,
|
|
16
|
+
} from "./SpokenPhraseOverlay";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
ReadAloudController,
|
|
20
|
+
LiveTranscriber,
|
|
21
|
+
isTranscriptionSupported,
|
|
22
|
+
type ReadAloudChunk,
|
|
23
|
+
type ReadAloudOptions,
|
|
24
|
+
type ReadAloudState,
|
|
25
|
+
type LiveTranscriberOptions,
|
|
26
|
+
type TranscriberEngine,
|
|
27
|
+
} from "../client";
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview React binding for `LiveTranscriber`.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the two streams a dictation UI needs: `partial`, which changes on every
|
|
5
|
+
* word while a phrase is in progress, and the committed phrases delivered through
|
|
6
|
+
* `onCommit`. `lastPhrase` carries whatever was most recently heard — partial or
|
|
7
|
+
* committed — along with a monotonically increasing `phraseId` so a display can
|
|
8
|
+
* re-trigger its own timeout even when the same words are said twice in a row.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
LiveTranscriber,
|
|
14
|
+
isTranscriptionSupported,
|
|
15
|
+
type LiveTranscriberOptions,
|
|
16
|
+
} from "../client/live-transcriber";
|
|
17
|
+
|
|
18
|
+
export interface UseLiveTranscriptionOptions
|
|
19
|
+
extends Omit<LiveTranscriberOptions, "onStateChange"> {}
|
|
20
|
+
|
|
21
|
+
export interface UseLiveTranscriptionReturn {
|
|
22
|
+
isListening: boolean;
|
|
23
|
+
/** True when this browser can dictate at all. */
|
|
24
|
+
isSupported: boolean;
|
|
25
|
+
/** The in-progress phrase, updating as it is spoken. Empty between phrases. */
|
|
26
|
+
partial: string;
|
|
27
|
+
/** The most recent thing heard, partial or committed. */
|
|
28
|
+
lastPhrase: string;
|
|
29
|
+
/** Increments on every `lastPhrase` update, including repeats of the same words. */
|
|
30
|
+
phraseId: number;
|
|
31
|
+
error: Error | null;
|
|
32
|
+
start: () => Promise<void>;
|
|
33
|
+
stop: () => Promise<void>;
|
|
34
|
+
toggle: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function useLiveTranscription(
|
|
38
|
+
options: UseLiveTranscriptionOptions = {}
|
|
39
|
+
): UseLiveTranscriptionReturn {
|
|
40
|
+
const [isListening, setIsListening] = useState(false);
|
|
41
|
+
const [isSupported, setIsSupported] = useState(false);
|
|
42
|
+
const [partial, setPartial] = useState("");
|
|
43
|
+
const [lastPhrase, setLastPhrase] = useState("");
|
|
44
|
+
const [phraseId, setPhraseId] = useState(0);
|
|
45
|
+
const [error, setError] = useState<Error | null>(null);
|
|
46
|
+
|
|
47
|
+
const optionsRef = useRef(options);
|
|
48
|
+
optionsRef.current = options;
|
|
49
|
+
|
|
50
|
+
// Support depends on `window`, so it can only be settled after hydration.
|
|
51
|
+
useEffect(() => setIsSupported(isTranscriptionSupported()), []);
|
|
52
|
+
|
|
53
|
+
const transcriber = useMemo(
|
|
54
|
+
() =>
|
|
55
|
+
new LiveTranscriber({
|
|
56
|
+
onStateChange: setIsListening,
|
|
57
|
+
onPartial: (text) => {
|
|
58
|
+
setPartial(text);
|
|
59
|
+
if (text) {
|
|
60
|
+
setLastPhrase(text);
|
|
61
|
+
setPhraseId((id) => id + 1);
|
|
62
|
+
}
|
|
63
|
+
optionsRef.current.onPartial?.(text);
|
|
64
|
+
},
|
|
65
|
+
onCommit: (text) => {
|
|
66
|
+
setPartial("");
|
|
67
|
+
setLastPhrase(text);
|
|
68
|
+
setPhraseId((id) => id + 1);
|
|
69
|
+
optionsRef.current.onCommit?.(text);
|
|
70
|
+
},
|
|
71
|
+
onError: (err) => {
|
|
72
|
+
setError(err);
|
|
73
|
+
optionsRef.current.onError?.(err);
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
[]
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Engine/language changes take effect on the next `start()`.
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
transcriber.setOptions({
|
|
82
|
+
engine: options.engine,
|
|
83
|
+
language: options.language,
|
|
84
|
+
model: options.model,
|
|
85
|
+
onStateChange: setIsListening,
|
|
86
|
+
onPartial: (text) => {
|
|
87
|
+
setPartial(text);
|
|
88
|
+
if (text) {
|
|
89
|
+
setLastPhrase(text);
|
|
90
|
+
setPhraseId((id) => id + 1);
|
|
91
|
+
}
|
|
92
|
+
optionsRef.current.onPartial?.(text);
|
|
93
|
+
},
|
|
94
|
+
onCommit: (text) => {
|
|
95
|
+
setPartial("");
|
|
96
|
+
setLastPhrase(text);
|
|
97
|
+
setPhraseId((id) => id + 1);
|
|
98
|
+
optionsRef.current.onCommit?.(text);
|
|
99
|
+
},
|
|
100
|
+
onError: (err) => {
|
|
101
|
+
setError(err);
|
|
102
|
+
optionsRef.current.onError?.(err);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}, [transcriber, options.engine, options.language, options.model]);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
return () => {
|
|
109
|
+
void transcriber.stop();
|
|
110
|
+
};
|
|
111
|
+
}, [transcriber]);
|
|
112
|
+
|
|
113
|
+
const start = useCallback(async () => {
|
|
114
|
+
setError(null);
|
|
115
|
+
await transcriber.start();
|
|
116
|
+
}, [transcriber]);
|
|
117
|
+
|
|
118
|
+
const stop = useCallback(async () => {
|
|
119
|
+
await transcriber.stop();
|
|
120
|
+
setPartial("");
|
|
121
|
+
}, [transcriber]);
|
|
122
|
+
|
|
123
|
+
const toggle = useCallback(async () => {
|
|
124
|
+
if (transcriber.isListening()) {
|
|
125
|
+
await stop();
|
|
126
|
+
} else {
|
|
127
|
+
await start();
|
|
128
|
+
}
|
|
129
|
+
}, [transcriber, start, stop]);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
isListening,
|
|
133
|
+
isSupported,
|
|
134
|
+
partial,
|
|
135
|
+
lastPhrase,
|
|
136
|
+
phraseId,
|
|
137
|
+
error,
|
|
138
|
+
start,
|
|
139
|
+
stop,
|
|
140
|
+
toggle,
|
|
141
|
+
};
|
|
142
|
+
}
|