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/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
@@ -43,5 +43,5 @@
43
43
  },
44
44
  "type": "module",
45
45
  "types": "./dist/index.d.ts",
46
- "version": "0.25.0"
46
+ "version": "0.27.0"
47
47
  }