solid-drift 0.25.0 → 0.27.0
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 +66 -0
- package/dist/hardware.d.ts +207 -0
- package/dist/hardware.js +631 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/voice.d.ts +292 -0
- package/dist/voice.js +577 -0
- package/package.json +1 -1
package/dist/voice.js
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { schedule } from "./engine.js";
|
|
3
|
+
import { prefersReducedMotion } from "./reduced-motion.js";
|
|
4
|
+
/* ------------------------------------------------------------------ */
|
|
5
|
+
/* Shared browser globals (stub-friendly for tests) */
|
|
6
|
+
/* ------------------------------------------------------------------ */
|
|
7
|
+
const g = globalThis;
|
|
8
|
+
function getSpeechRecognitionCtor() {
|
|
9
|
+
const ctor = g["SpeechRecognition"] ?? g["webkitSpeechRecognition"];
|
|
10
|
+
return ctor ?? null;
|
|
11
|
+
}
|
|
12
|
+
function getAudioContextCtor() {
|
|
13
|
+
const ctor = g["AudioContext"] ?? g["webkitAudioContext"];
|
|
14
|
+
return ctor ?? null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The state machine behind a voice assistant turn: the user speaks
|
|
18
|
+
* (`listening`), the app works (`thinking`), the app replies
|
|
19
|
+
* (`speaking`), then back to `idle`. Wire the phases to
|
|
20
|
+
* `createSpeech` (listening), your model call (thinking), and
|
|
21
|
+
* `createTTS` (speaking).
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* const voice = createVoiceState();
|
|
25
|
+
* voice.toListening(); // mic open
|
|
26
|
+
* // ... transcript arrives
|
|
27
|
+
* voice.toThinking(); // calling the model
|
|
28
|
+
* // ... reply ready
|
|
29
|
+
* voice.toSpeaking(); // TTS playing
|
|
30
|
+
* voice.toIdle();
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function createVoiceState() {
|
|
34
|
+
const [state, setState] = createSignal("idle");
|
|
35
|
+
return {
|
|
36
|
+
state,
|
|
37
|
+
toIdle: () => setState("idle"),
|
|
38
|
+
toListening: () => setState("listening"),
|
|
39
|
+
toThinking: () => setState("thinking"),
|
|
40
|
+
toSpeaking: () => setState("speaking"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* A live microphone volume meter: `getUserMedia` plus an
|
|
45
|
+
* `AnalyserNode`, smoothed to a 0..1 level signal.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const mic = createMicLevel();
|
|
49
|
+
* // in a click handler:
|
|
50
|
+
* await mic.start();
|
|
51
|
+
* <div style={{ width: `${mic.level() * 100}%` }} />
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* SSR-safe: `supported` is false without WebAudio/getUserMedia and
|
|
55
|
+
* `start()` rejects there.
|
|
56
|
+
*/
|
|
57
|
+
export function createMicLevel(options = {}) {
|
|
58
|
+
const { fftSize = 512, smoothing = 0.7, gain = 3, onLevel } = options;
|
|
59
|
+
const [level, setLevel] = createSignal(0);
|
|
60
|
+
const [active, setActive] = createSignal(false);
|
|
61
|
+
const [analyser, setAnalyser] = createSignal(null);
|
|
62
|
+
const [error, setError] = createSignal(null);
|
|
63
|
+
const supported = getAudioContextCtor() !== null &&
|
|
64
|
+
typeof g["navigator"]?.mediaDevices
|
|
65
|
+
?.getUserMedia === "function";
|
|
66
|
+
let context = null;
|
|
67
|
+
let stream = null;
|
|
68
|
+
let ownsStream = false;
|
|
69
|
+
let cancelLoop = null;
|
|
70
|
+
let current = 0;
|
|
71
|
+
const stop = () => {
|
|
72
|
+
cancelLoop?.();
|
|
73
|
+
cancelLoop = null;
|
|
74
|
+
if (ownsStream) {
|
|
75
|
+
stream?.getTracks().forEach((t) => t.stop());
|
|
76
|
+
}
|
|
77
|
+
stream = null;
|
|
78
|
+
ownsStream = false;
|
|
79
|
+
if (context) {
|
|
80
|
+
void context.close().catch(() => { });
|
|
81
|
+
context = null;
|
|
82
|
+
}
|
|
83
|
+
setAnalyser(null);
|
|
84
|
+
current = 0;
|
|
85
|
+
setLevel(0);
|
|
86
|
+
setActive(false);
|
|
87
|
+
};
|
|
88
|
+
const start = async () => {
|
|
89
|
+
if (active())
|
|
90
|
+
return;
|
|
91
|
+
setError(null);
|
|
92
|
+
try {
|
|
93
|
+
const Ctor = getAudioContextCtor();
|
|
94
|
+
const gum = g["navigator"]?.mediaDevices
|
|
95
|
+
?.getUserMedia;
|
|
96
|
+
if (!Ctor || !gum) {
|
|
97
|
+
throw new Error("createMicLevel: WebAudio or getUserMedia is not available");
|
|
98
|
+
}
|
|
99
|
+
if (options.stream) {
|
|
100
|
+
stream = options.stream;
|
|
101
|
+
ownsStream = false;
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
stream = await gum.call(g["navigator"].mediaDevices, { audio: true });
|
|
105
|
+
ownsStream = true;
|
|
106
|
+
}
|
|
107
|
+
context = new Ctor();
|
|
108
|
+
if (context.state === "suspended") {
|
|
109
|
+
await context.resume().catch(() => { });
|
|
110
|
+
}
|
|
111
|
+
const node = context.createAnalyser();
|
|
112
|
+
node.fftSize = fftSize;
|
|
113
|
+
context.createMediaStreamSource(stream).connect(node);
|
|
114
|
+
setAnalyser(node);
|
|
115
|
+
const data = new Uint8Array(node.frequencyBinCount);
|
|
116
|
+
setActive(true);
|
|
117
|
+
cancelLoop = schedule(() => {
|
|
118
|
+
if (!active())
|
|
119
|
+
return false;
|
|
120
|
+
node.getByteTimeDomainData(data);
|
|
121
|
+
let sum = 0;
|
|
122
|
+
for (let i = 0; i < data.length; i++) {
|
|
123
|
+
const v = (data[i] - 128) / 128;
|
|
124
|
+
sum += v * v;
|
|
125
|
+
}
|
|
126
|
+
const rms = Math.sqrt(sum / data.length);
|
|
127
|
+
const target = Math.min(1, rms * gain);
|
|
128
|
+
current = smoothing * current + (1 - smoothing) * target;
|
|
129
|
+
setLevel(current);
|
|
130
|
+
onLevel?.(current);
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
stop();
|
|
136
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
137
|
+
setError(err);
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
onCleanup(stop);
|
|
142
|
+
return { level, active, supported, analyser, error, start, stop };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Speech-to-text via the Web Speech API (`SpeechRecognition` with
|
|
146
|
+
* `webkitSpeechRecognition` fallback).
|
|
147
|
+
*
|
|
148
|
+
* ```ts
|
|
149
|
+
* const speech = createSpeech({ lang: "en-US" });
|
|
150
|
+
* // in a click handler:
|
|
151
|
+
* speech.start();
|
|
152
|
+
* <p>{speech.transcript()}<span class="dim">{speech.interim()}</span></p>
|
|
153
|
+
* ```
|
|
154
|
+
*
|
|
155
|
+
* SSR-safe: `supported` is false without the API and `start()` is a
|
|
156
|
+
* no-op there. Note the API itself is a browser/cloud service with
|
|
157
|
+
* per-browser availability and limits.
|
|
158
|
+
*/
|
|
159
|
+
export function createSpeech(options = {}) {
|
|
160
|
+
const { continuous = false, interimResults = true, onResult, onError, } = options;
|
|
161
|
+
const [listening, setListening] = createSignal(false);
|
|
162
|
+
const [transcript, setTranscript] = createSignal("");
|
|
163
|
+
const [interim, setInterim] = createSignal("");
|
|
164
|
+
const [error, setError] = createSignal(null);
|
|
165
|
+
const Ctor = getSpeechRecognitionCtor();
|
|
166
|
+
const supported = Ctor !== null;
|
|
167
|
+
let recognition = null;
|
|
168
|
+
let wantListening = false;
|
|
169
|
+
const ensure = () => {
|
|
170
|
+
if (!Ctor)
|
|
171
|
+
return null;
|
|
172
|
+
if (!recognition) {
|
|
173
|
+
recognition = new Ctor();
|
|
174
|
+
recognition.lang =
|
|
175
|
+
options.lang ??
|
|
176
|
+
g["navigator"]?.language ??
|
|
177
|
+
"en-US";
|
|
178
|
+
recognition.continuous = continuous;
|
|
179
|
+
recognition.interimResults = interimResults;
|
|
180
|
+
recognition.onstart = () => setListening(true);
|
|
181
|
+
recognition.onresult = (event) => {
|
|
182
|
+
let interimText = "";
|
|
183
|
+
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
184
|
+
const result = event.results[i];
|
|
185
|
+
const text = result[0]?.transcript ?? "";
|
|
186
|
+
if (result.isFinal) {
|
|
187
|
+
setTranscript((prev) => prev + text);
|
|
188
|
+
onResult?.(text, true);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
interimText += text;
|
|
192
|
+
onResult?.(text, false);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
setInterim(interimText);
|
|
196
|
+
};
|
|
197
|
+
recognition.onerror = (event) => {
|
|
198
|
+
const err = new Error(`Speech recognition error: ${event.error}${event.message ? ` - ${event.message}` : ""}`);
|
|
199
|
+
setError(err);
|
|
200
|
+
onError?.(err);
|
|
201
|
+
};
|
|
202
|
+
recognition.onend = () => {
|
|
203
|
+
setListening(false);
|
|
204
|
+
// Chrome ends the session on silence even when continuous;
|
|
205
|
+
// restart while the user still wants to listen.
|
|
206
|
+
if (wantListening && continuous && recognition) {
|
|
207
|
+
try {
|
|
208
|
+
recognition.start();
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
wantListening = false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return recognition;
|
|
217
|
+
};
|
|
218
|
+
const start = () => {
|
|
219
|
+
const rec = ensure();
|
|
220
|
+
if (!rec)
|
|
221
|
+
return;
|
|
222
|
+
setError(null);
|
|
223
|
+
wantListening = true;
|
|
224
|
+
try {
|
|
225
|
+
rec.start();
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Already started; the running session stands.
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const stop = () => {
|
|
232
|
+
wantListening = false;
|
|
233
|
+
try {
|
|
234
|
+
recognition?.stop();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Already stopped.
|
|
238
|
+
}
|
|
239
|
+
setListening(false);
|
|
240
|
+
};
|
|
241
|
+
const reset = () => {
|
|
242
|
+
setTranscript("");
|
|
243
|
+
setInterim("");
|
|
244
|
+
};
|
|
245
|
+
onCleanup(stop);
|
|
246
|
+
return { supported, listening, transcript, interim, error, start, stop, reset };
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* A canvas waveform renderer driven by an `AnalyserNode`.
|
|
250
|
+
*
|
|
251
|
+
* ```tsx
|
|
252
|
+
* const mic = createMicLevel();
|
|
253
|
+
* createWaveform(() => canvas, { analyser: mic.analyser, mode: "bars" });
|
|
254
|
+
* <canvas ref={canvas} style={{ width: "100%", height: "64px" }} />
|
|
255
|
+
* ```
|
|
256
|
+
*
|
|
257
|
+
* The canvas is sized to its CSS box times the device pixel ratio.
|
|
258
|
+
* Under reduced motion it redraws at most every 250ms. SSR-safe:
|
|
259
|
+
* nothing renders without a canvas.
|
|
260
|
+
*/
|
|
261
|
+
export function createWaveform(canvas, options) {
|
|
262
|
+
const { analyser, color = "#3e6c99", lineWidth = 2, mode = "line", background = null, enabled = true, } = options;
|
|
263
|
+
const [active, setActive] = createSignal(false);
|
|
264
|
+
const isEnabled = () => typeof enabled === "function" ? enabled() : enabled;
|
|
265
|
+
if (typeof window === "undefined")
|
|
266
|
+
return { active };
|
|
267
|
+
let cancel = null;
|
|
268
|
+
let lastDraw = 0;
|
|
269
|
+
const reduced = prefersReducedMotion();
|
|
270
|
+
const draw = (t) => {
|
|
271
|
+
if (!isEnabled()) {
|
|
272
|
+
setActive(false);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
setActive(true);
|
|
276
|
+
if (reduced && t - lastDraw < 250)
|
|
277
|
+
return true;
|
|
278
|
+
lastDraw = t;
|
|
279
|
+
const el = canvas();
|
|
280
|
+
const node = analyser();
|
|
281
|
+
if (!el || typeof el.getContext !== "function")
|
|
282
|
+
return true;
|
|
283
|
+
const dpr = Math.min(2, g["devicePixelRatio"] ?? 1);
|
|
284
|
+
const w = el.clientWidth * dpr;
|
|
285
|
+
const h = el.clientHeight * dpr;
|
|
286
|
+
if (w <= 0 || h <= 0)
|
|
287
|
+
return true;
|
|
288
|
+
if (el.width !== Math.round(w) || el.height !== Math.round(h)) {
|
|
289
|
+
el.width = Math.round(w);
|
|
290
|
+
el.height = Math.round(h);
|
|
291
|
+
}
|
|
292
|
+
const ctx = el.getContext("2d");
|
|
293
|
+
if (!ctx)
|
|
294
|
+
return true;
|
|
295
|
+
ctx.clearRect(0, 0, el.width, el.height);
|
|
296
|
+
if (background) {
|
|
297
|
+
ctx.fillStyle = background;
|
|
298
|
+
ctx.fillRect(0, 0, el.width, el.height);
|
|
299
|
+
}
|
|
300
|
+
ctx.strokeStyle = color;
|
|
301
|
+
ctx.fillStyle = color;
|
|
302
|
+
ctx.lineWidth = lineWidth * dpr;
|
|
303
|
+
if (!node) {
|
|
304
|
+
// Idle baseline.
|
|
305
|
+
ctx.beginPath();
|
|
306
|
+
ctx.moveTo(0, el.height / 2);
|
|
307
|
+
ctx.lineTo(el.width, el.height / 2);
|
|
308
|
+
ctx.stroke();
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (mode === "bars") {
|
|
312
|
+
const data = new Uint8Array(node.frequencyBinCount);
|
|
313
|
+
node.getByteFrequencyData(data);
|
|
314
|
+
const bars = Math.min(64, data.length);
|
|
315
|
+
const bw = el.width / bars;
|
|
316
|
+
for (let i = 0; i < bars; i++) {
|
|
317
|
+
const v = data[Math.floor((i / bars) * data.length)] / 255;
|
|
318
|
+
const bh = Math.max(2 * dpr, v * el.height);
|
|
319
|
+
ctx.fillRect(i * bw + bw * 0.15, el.height - bh, bw * 0.7, bh);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const data = new Uint8Array(node.frequencyBinCount);
|
|
324
|
+
node.getByteTimeDomainData(data);
|
|
325
|
+
ctx.beginPath();
|
|
326
|
+
const mid = el.height / 2;
|
|
327
|
+
for (let i = 0; i < data.length; i++) {
|
|
328
|
+
const x = (i / (data.length - 1)) * el.width;
|
|
329
|
+
const y = mid + ((data[i] - 128) / 128) * mid;
|
|
330
|
+
if (i === 0)
|
|
331
|
+
ctx.moveTo(x, y);
|
|
332
|
+
else
|
|
333
|
+
ctx.lineTo(x, y);
|
|
334
|
+
}
|
|
335
|
+
ctx.stroke();
|
|
336
|
+
}
|
|
337
|
+
return true;
|
|
338
|
+
};
|
|
339
|
+
cancel = schedule(draw);
|
|
340
|
+
onCleanup(() => {
|
|
341
|
+
cancel?.();
|
|
342
|
+
setActive(false);
|
|
343
|
+
});
|
|
344
|
+
return { active };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Text-to-speech with `speechSynthesis` by default and a clean
|
|
348
|
+
* upgrade path to cloud voices.
|
|
349
|
+
*
|
|
350
|
+
* ```ts
|
|
351
|
+
* const tts = createTTS({ rate: 1.05 });
|
|
352
|
+
* tts.speak("Your report is ready.");
|
|
353
|
+
*
|
|
354
|
+
* // Cloud upgrade:
|
|
355
|
+
* const tts = createTTS({
|
|
356
|
+
* provider: {
|
|
357
|
+
* speak: async (text, { signal }) => {
|
|
358
|
+
* const res = await fetch("/api/tts", {
|
|
359
|
+
* method: "POST",
|
|
360
|
+
* body: JSON.stringify({ text }),
|
|
361
|
+
* signal,
|
|
362
|
+
* });
|
|
363
|
+
* await playAudioBlob(await res.blob(), signal);
|
|
364
|
+
* },
|
|
365
|
+
* },
|
|
366
|
+
* });
|
|
367
|
+
* ```
|
|
368
|
+
*
|
|
369
|
+
* SSR-safe: `supported` is false without `speechSynthesis` and
|
|
370
|
+
* `speak()` is a no-op there.
|
|
371
|
+
*/
|
|
372
|
+
export function createTTS(options = {}) {
|
|
373
|
+
const { provider = "browser", rate = 1, pitch = 1, volume = 1, voice, onStart, onEnd, onError, } = options;
|
|
374
|
+
const [speaking, setSpeaking] = createSignal(false);
|
|
375
|
+
const [voices, setVoices] = createSignal([]);
|
|
376
|
+
const synth = g["speechSynthesis"] ?? null;
|
|
377
|
+
const Utterance = g["SpeechSynthesisUtterance"] ?? null;
|
|
378
|
+
const supported = provider !== "browser" || (synth !== null && Utterance !== null);
|
|
379
|
+
let cloudController = null;
|
|
380
|
+
const refreshVoices = () => {
|
|
381
|
+
if (synth)
|
|
382
|
+
setVoices([...synth.getVoices()]);
|
|
383
|
+
};
|
|
384
|
+
if (synth) {
|
|
385
|
+
refreshVoices();
|
|
386
|
+
const handler = () => refreshVoices();
|
|
387
|
+
synth.addEventListener("voiceschanged", handler);
|
|
388
|
+
onCleanup(() => synth.removeEventListener("voiceschanged", handler));
|
|
389
|
+
}
|
|
390
|
+
const pickVoice = () => {
|
|
391
|
+
const list = voices();
|
|
392
|
+
if (!voice || list.length === 0)
|
|
393
|
+
return undefined;
|
|
394
|
+
if (typeof voice === "function")
|
|
395
|
+
return voice(list);
|
|
396
|
+
return list.find((v) => v.name === voice) ?? undefined;
|
|
397
|
+
};
|
|
398
|
+
const cancel = () => {
|
|
399
|
+
cloudController?.abort();
|
|
400
|
+
cloudController = null;
|
|
401
|
+
try {
|
|
402
|
+
synth?.cancel();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Already idle.
|
|
406
|
+
}
|
|
407
|
+
setSpeaking(false);
|
|
408
|
+
};
|
|
409
|
+
const speak = (text) => {
|
|
410
|
+
if (!text)
|
|
411
|
+
return;
|
|
412
|
+
cancel();
|
|
413
|
+
if (typeof provider === "object") {
|
|
414
|
+
cloudController = new AbortController();
|
|
415
|
+
const signal = cloudController.signal;
|
|
416
|
+
setSpeaking(true);
|
|
417
|
+
onStart?.();
|
|
418
|
+
void provider
|
|
419
|
+
.speak(text, { signal })
|
|
420
|
+
.then(() => {
|
|
421
|
+
if (signal.aborted)
|
|
422
|
+
return;
|
|
423
|
+
setSpeaking(false);
|
|
424
|
+
onEnd?.();
|
|
425
|
+
})
|
|
426
|
+
.catch((e) => {
|
|
427
|
+
if (signal.aborted)
|
|
428
|
+
return;
|
|
429
|
+
setSpeaking(false);
|
|
430
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
431
|
+
onError?.(err);
|
|
432
|
+
});
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (!synth || !Utterance)
|
|
436
|
+
return;
|
|
437
|
+
const utterance = new Utterance(text);
|
|
438
|
+
utterance.lang =
|
|
439
|
+
options.lang ??
|
|
440
|
+
g["navigator"]?.language ??
|
|
441
|
+
"en-US";
|
|
442
|
+
utterance.rate = rate;
|
|
443
|
+
utterance.pitch = pitch;
|
|
444
|
+
utterance.volume = volume;
|
|
445
|
+
const chosen = pickVoice();
|
|
446
|
+
if (chosen)
|
|
447
|
+
utterance.voice = chosen;
|
|
448
|
+
utterance.onstart = () => {
|
|
449
|
+
setSpeaking(true);
|
|
450
|
+
onStart?.();
|
|
451
|
+
};
|
|
452
|
+
utterance.onend = () => {
|
|
453
|
+
setSpeaking(false);
|
|
454
|
+
onEnd?.();
|
|
455
|
+
};
|
|
456
|
+
utterance.onerror = () => {
|
|
457
|
+
setSpeaking(false);
|
|
458
|
+
const err = new Error("Speech synthesis failed");
|
|
459
|
+
onError?.(err);
|
|
460
|
+
};
|
|
461
|
+
synth.speak(utterance);
|
|
462
|
+
};
|
|
463
|
+
onCleanup(cancel);
|
|
464
|
+
return { supported, speaking, voices, speak, cancel };
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* An animated "thinking" indicator for voice reply latency: cycles
|
|
468
|
+
* trailing dots, then moves through phrases.
|
|
469
|
+
*
|
|
470
|
+
* ```ts
|
|
471
|
+
* const thinking = createThinking({ phrases: ["Thinking", "Searching"] });
|
|
472
|
+
* thinking.start();
|
|
473
|
+
* <p>{thinking.text()}</p> // "Thinking", "Thinking.", "Thinking..", ...
|
|
474
|
+
* ```
|
|
475
|
+
*/
|
|
476
|
+
export function createThinking(options = {}) {
|
|
477
|
+
const { phrases = ["Thinking"], interval = 400 } = options;
|
|
478
|
+
const safePhrases = phrases.length > 0 ? phrases : ["Thinking"];
|
|
479
|
+
const [text, setText] = createSignal("");
|
|
480
|
+
const [running, setRunning] = createSignal(false);
|
|
481
|
+
let timer = null;
|
|
482
|
+
let phrase = 0;
|
|
483
|
+
let dots = 0;
|
|
484
|
+
const stopTimer = () => {
|
|
485
|
+
if (timer !== null) {
|
|
486
|
+
clearInterval(timer);
|
|
487
|
+
timer = null;
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const start = () => {
|
|
491
|
+
stopTimer();
|
|
492
|
+
phrase = 0;
|
|
493
|
+
dots = 0;
|
|
494
|
+
setRunning(true);
|
|
495
|
+
setText(safePhrases[0]);
|
|
496
|
+
timer = setInterval(() => {
|
|
497
|
+
dots = (dots + 1) % 4;
|
|
498
|
+
if (dots === 0)
|
|
499
|
+
phrase = (phrase + 1) % safePhrases.length;
|
|
500
|
+
setText(safePhrases[phrase] + ".".repeat(dots));
|
|
501
|
+
}, interval);
|
|
502
|
+
};
|
|
503
|
+
const stop = () => {
|
|
504
|
+
stopTimer();
|
|
505
|
+
setRunning(false);
|
|
506
|
+
setText("");
|
|
507
|
+
};
|
|
508
|
+
onCleanup(stopTimer);
|
|
509
|
+
return { text, running, start, stop };
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* A voice-enabled prompt input: a text value plus mic dictation,
|
|
513
|
+
* built for chat boxes and voice assistants. Dictated finals are
|
|
514
|
+
* appended to the value as they arrive; pairs with `createChatModel`
|
|
515
|
+
* for a full voice loop.
|
|
516
|
+
*
|
|
517
|
+
* ```tsx
|
|
518
|
+
* const prompt = createPrompt({ onSubmit: (t) => chat.send(t) });
|
|
519
|
+
* <input
|
|
520
|
+
* value={prompt.value()}
|
|
521
|
+
* onInput={(e) => prompt.setValue(e.currentTarget.value)}
|
|
522
|
+
* onKeyDown={(e) => e.key === "Enter" && prompt.submit()}
|
|
523
|
+
* placeholder={prompt.listening() ? prompt.interim() || "Listening..." : "Ask anything"}
|
|
524
|
+
* />
|
|
525
|
+
* <button onClick={() => prompt.toggleMic()}>Mic</button>
|
|
526
|
+
* ```
|
|
527
|
+
*/
|
|
528
|
+
export function createPrompt(options = {}) {
|
|
529
|
+
const { onSubmit, clearOnSubmit = true, lang } = options;
|
|
530
|
+
const [value, setValue] = createSignal("");
|
|
531
|
+
const speech = createSpeech({
|
|
532
|
+
lang,
|
|
533
|
+
continuous: true,
|
|
534
|
+
onResult: (transcript, isFinal) => {
|
|
535
|
+
if (!isFinal)
|
|
536
|
+
return;
|
|
537
|
+
setValue((prev) => {
|
|
538
|
+
const needsSpace = prev.length > 0 && !/\s$/.test(prev);
|
|
539
|
+
return prev + (needsSpace ? " " : "") + transcript.trim();
|
|
540
|
+
});
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const toggleMic = () => {
|
|
544
|
+
if (speech.listening()) {
|
|
545
|
+
speech.stop();
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
speech.reset();
|
|
549
|
+
speech.start();
|
|
550
|
+
}
|
|
551
|
+
};
|
|
552
|
+
const clear = () => {
|
|
553
|
+
setValue("");
|
|
554
|
+
speech.reset();
|
|
555
|
+
};
|
|
556
|
+
const submit = () => {
|
|
557
|
+
const text = value().trim();
|
|
558
|
+
if (speech.listening())
|
|
559
|
+
speech.stop();
|
|
560
|
+
if (!text)
|
|
561
|
+
return;
|
|
562
|
+
onSubmit?.(text);
|
|
563
|
+
if (clearOnSubmit)
|
|
564
|
+
clear();
|
|
565
|
+
};
|
|
566
|
+
onCleanup(() => speech.stop());
|
|
567
|
+
return {
|
|
568
|
+
value,
|
|
569
|
+
setValue,
|
|
570
|
+
listening: speech.listening,
|
|
571
|
+
interim: speech.interim,
|
|
572
|
+
supported: speech.supported,
|
|
573
|
+
toggleMic,
|
|
574
|
+
submit,
|
|
575
|
+
clear,
|
|
576
|
+
};
|
|
577
|
+
}
|
package/package.json
CHANGED