solid-drift 0.25.0 → 0.26.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 +37 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/voice.d.ts +292 -0
- package/dist/voice.js +577 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1534,6 +1534,43 @@ const chat = createChatModel({
|
|
|
1534
1534
|
- Honest limitation: api.anthropic.com does not send CORS headers for browser origins, so from a browser Anthropic must go through your own server route or proxy; point `baseUrl` at it. For production with any provider, prefer a server route that holds the key and set `baseUrl` to it so keys never ship to the browser.
|
|
1535
1535
|
- `createSSE(url, options?)` is a fetch-based event-stream client (`{ status, events, lastEvent, error, connect, disconnect }`): unlike `EventSource` it supports any method and custom headers, parses the full SSE framing (named events, multi-line data, comments, chunk splits), and leaves reconnection manual via `connect()`. SSR-safe: nothing connects until `connect()` (or `autoConnect`) runs on the client.
|
|
1536
1536
|
|
|
1537
|
+
### Voice
|
|
1538
|
+
|
|
1539
|
+
A full voice loop: mic metering, speech-to-text, a voice state machine, canvas waveforms, text-to-speech (browser or cloud), thinking indicators, and a voice-enabled prompt input.
|
|
1540
|
+
|
|
1541
|
+
```tsx
|
|
1542
|
+
import { createVoiceState, createSpeech, createTTS, createPrompt, createChatModel } from "solid-drift"
|
|
1543
|
+
|
|
1544
|
+
const voice = createVoiceState()
|
|
1545
|
+
const chat = createChatModel({ provider: "openai", apiKey: getKey, model: "gpt-4o-mini" })
|
|
1546
|
+
const tts = createTTS()
|
|
1547
|
+
const prompt = createPrompt({
|
|
1548
|
+
onSubmit: async (text) => {
|
|
1549
|
+
voice.toThinking()
|
|
1550
|
+
await chat.send(text)
|
|
1551
|
+
voice.toSpeaking()
|
|
1552
|
+
const msgs = chat.messages()
|
|
1553
|
+
tts.speak(msgs[msgs.length - 1]?.content ?? "")
|
|
1554
|
+
voice.toIdle()
|
|
1555
|
+
},
|
|
1556
|
+
})
|
|
1557
|
+
|
|
1558
|
+
<input
|
|
1559
|
+
value={prompt.value()}
|
|
1560
|
+
onInput={(e) => prompt.setValue(e.currentTarget.value)}
|
|
1561
|
+
onKeyDown={(e) => e.key === "Enter" && prompt.submit()}
|
|
1562
|
+
/>
|
|
1563
|
+
<button onClick={() => { voice.toListening(); prompt.toggleMic(); }}>Mic</button>
|
|
1564
|
+
```
|
|
1565
|
+
|
|
1566
|
+
- `createVoiceState()` is the turn state machine: `state()` is `idle`, `listening`, `thinking`, or `speaking`, with `toIdle`/`toListening`/`toThinking`/`toSpeaking` transitions.
|
|
1567
|
+
- `createMicLevel(options?)` returns `{ level, active, supported, analyser, error, start, stop }`: a 0..1 smoothed RMS meter from `getUserMedia` plus an `AnalyserNode` (call `start()` from a user gesture). The exposed `analyser` wires straight into `createWaveform`.
|
|
1568
|
+
- `createSpeech(options?)` wraps the Web Speech API (`SpeechRecognition` with `webkitSpeechRecognition` fallback): `{ supported, listening, transcript, interim, error, start, stop, reset }`. Final results accumulate into `transcript()`; `continuous` sessions auto-restart if the browser ends them mid-turn.
|
|
1569
|
+
- `createWaveform(canvas, options)` draws the analyser's time-domain wave (`mode: "line"`) or spectrum (`mode: "bars"`) on a canvas, DPR-aware, on the shared clock; under reduced motion it redraws at most every 250ms.
|
|
1570
|
+
- `createTTS(options?)` speaks via `speechSynthesis` by default (`{ supported, speaking, voices, speak, cancel }`, async voice loading, `speak()` cancels the current utterance first) and upgrades to any cloud voice through `provider: { speak(text, { signal }) }`.
|
|
1571
|
+
- `createThinking(options?)` cycles `"Thinking"`, `"Thinking."`, ... through `phrases` at `interval` ms: `{ text, running, start, stop }`.
|
|
1572
|
+
- `createPrompt(options?)` is the voice-enabled input: `{ value, setValue, listening, interim, supported, toggleMic, submit, clear }`. Mic finals are appended to the value as they arrive; `submit()` fires `onSubmit` and clears by default. Everything is SSR-safe: unsupported primitives report `supported: false` and their actions no-op on the server.
|
|
1573
|
+
|
|
1537
1574
|
### Easings
|
|
1538
1575
|
|
|
1539
1576
|
Named easings: `linear`, `easeInQuad`, `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeInOutCubic`, `easeInQuart`, `easeOutQuart`, `easeInOutQuart`, `easeOutExpo`, `easeOutBack`, plus the cartoon set: `easeInBack` (anticipation dip before movement), `easeInOutBack` (wind-up, overshoot, settle), `easeOutElastic` (decaying rubber-band oscillation), `easeOutBounce` (shrinking cartoon bounces). Also `cubicBezier(x1, y1, x2, y2)` for CSS-style curves. Pass a name or a custom `(t) => number` function anywhere an easing is accepted.
|
package/dist/index.d.ts
CHANGED
|
@@ -42,3 +42,5 @@ export { createPoll, shortenAddress, isAddress, formatUnits, parseUnits, CHAINS,
|
|
|
42
42
|
export type { PollStatus, PollOptions, PollControls, ChainInfo, TokenPrice, TokenPriceOptions, PriceChangeOptions, GasPriceOptions, GasPriceData, BalanceOptions, BalanceData, TxReceiptData, TxReceiptOptions, BlockNumberOptions, ChainlinkPriceOptions, NFTMetadata, NFTMetadataOptions, ENSOptions, IdenticonOptions, } from "./web3data.js";
|
|
43
43
|
export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
|
44
44
|
export type { SSEEvent, StreamStatus, SSEOptions, SSEControls, ChatMessage, ChatProviderKind, CustomChatProvider, ChatModelOptions, ChatModelControls, } from "./stream.js";
|
|
45
|
+
export { createVoiceState, createMicLevel, createSpeech, createWaveform, createTTS, createThinking, createPrompt, } from "./voice.js";
|
|
46
|
+
export type { VoiceStatus, VoiceStateControls, MicLevelOptions, MicLevelControls, SpeechOptions, SpeechControls, WaveformOptions, WaveformControls, TTSProvider, TTSOptions, TTSControls, ThinkingOptions, ThinkingControls, PromptOptions, PromptControls, } from "./voice.js";
|
package/dist/index.js
CHANGED
|
@@ -37,3 +37,4 @@ export { createStreamReveal, createAgentState, parseDriftSpec, createSpecPlayer,
|
|
|
37
37
|
export { createTxLifecycle, createTicker, createMintReveal, createConnectButton, createAgentTx, } from "./web3.js";
|
|
38
38
|
export { createPoll, shortenAddress, isAddress, formatUnits, parseUnits, CHAINS, createChain, createTokenPrice, createPriceChange, createPriceCompare, createGasPrice, createBalance, createTxReceipt, createBlockNumber, createChainlinkPrice, createNFTMetadata, createENS, createIdenticon, } from "./web3data.js";
|
|
39
39
|
export { createSSEParser, createSSE, createChatModel, } from "./stream.js";
|
|
40
|
+
export { createVoiceState, createMicLevel, createSpeech, createWaveform, createTTS, createThinking, createPrompt, } from "./voice.js";
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { type Accessor } from "solid-js";
|
|
2
|
+
type MaybeElement = () => Element | null | undefined;
|
|
3
|
+
/** Phase of a voice interaction. */
|
|
4
|
+
export type VoiceStatus = "idle" | "listening" | "thinking" | "speaking";
|
|
5
|
+
export interface VoiceStateControls {
|
|
6
|
+
/** Current phase. */
|
|
7
|
+
state: Accessor<VoiceStatus>;
|
|
8
|
+
toIdle: () => void;
|
|
9
|
+
toListening: () => void;
|
|
10
|
+
toThinking: () => void;
|
|
11
|
+
toSpeaking: () => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The state machine behind a voice assistant turn: the user speaks
|
|
15
|
+
* (`listening`), the app works (`thinking`), the app replies
|
|
16
|
+
* (`speaking`), then back to `idle`. Wire the phases to
|
|
17
|
+
* `createSpeech` (listening), your model call (thinking), and
|
|
18
|
+
* `createTTS` (speaking).
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const voice = createVoiceState();
|
|
22
|
+
* voice.toListening(); // mic open
|
|
23
|
+
* // ... transcript arrives
|
|
24
|
+
* voice.toThinking(); // calling the model
|
|
25
|
+
* // ... reply ready
|
|
26
|
+
* voice.toSpeaking(); // TTS playing
|
|
27
|
+
* voice.toIdle();
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function createVoiceState(): VoiceStateControls;
|
|
31
|
+
export interface MicLevelOptions {
|
|
32
|
+
/** Analyser FFT size. Default 512. */
|
|
33
|
+
fftSize?: number;
|
|
34
|
+
/** EMA smoothing factor 0..1 (higher = smoother). Default 0.7. */
|
|
35
|
+
smoothing?: number;
|
|
36
|
+
/** Gain applied to the RMS before clamping to 0..1. Default 3. */
|
|
37
|
+
gain?: number;
|
|
38
|
+
/** Use an existing stream instead of requesting the microphone. */
|
|
39
|
+
stream?: MediaStream;
|
|
40
|
+
/** Called with each smoothed level. */
|
|
41
|
+
onLevel?: (level: number) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface MicLevelControls {
|
|
44
|
+
/** Smoothed volume 0..1. */
|
|
45
|
+
level: Accessor<number>;
|
|
46
|
+
/** Whether the meter is running. */
|
|
47
|
+
active: Accessor<boolean>;
|
|
48
|
+
/** False on the server or without WebAudio/getUserMedia. */
|
|
49
|
+
supported: boolean;
|
|
50
|
+
/** The analyser node, for wiring into `createWaveform`. */
|
|
51
|
+
analyser: Accessor<AnalyserNode | null>;
|
|
52
|
+
/** The last error, if any. */
|
|
53
|
+
error: Accessor<Error | null>;
|
|
54
|
+
/** Request the mic (call from a user gesture) and start metering. */
|
|
55
|
+
start: () => Promise<void>;
|
|
56
|
+
/** Stop metering and release the microphone. */
|
|
57
|
+
stop: () => void;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A live microphone volume meter: `getUserMedia` plus an
|
|
61
|
+
* `AnalyserNode`, smoothed to a 0..1 level signal.
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* const mic = createMicLevel();
|
|
65
|
+
* // in a click handler:
|
|
66
|
+
* await mic.start();
|
|
67
|
+
* <div style={{ width: `${mic.level() * 100}%` }} />
|
|
68
|
+
* ```
|
|
69
|
+
*
|
|
70
|
+
* SSR-safe: `supported` is false without WebAudio/getUserMedia and
|
|
71
|
+
* `start()` rejects there.
|
|
72
|
+
*/
|
|
73
|
+
export declare function createMicLevel(options?: MicLevelOptions): MicLevelControls;
|
|
74
|
+
export interface SpeechOptions {
|
|
75
|
+
/** BCP-47 language tag. Default the browser language. */
|
|
76
|
+
lang?: string;
|
|
77
|
+
/** Keep listening across utterances. Default false. */
|
|
78
|
+
continuous?: boolean;
|
|
79
|
+
/** Emit interim (non-final) results. Default true. */
|
|
80
|
+
interimResults?: boolean;
|
|
81
|
+
/** Called per result: the result text and whether it is final. */
|
|
82
|
+
onResult?: (transcript: string, isFinal: boolean) => void;
|
|
83
|
+
/** Called on recognition errors. */
|
|
84
|
+
onError?: (error: Error) => void;
|
|
85
|
+
}
|
|
86
|
+
export interface SpeechControls {
|
|
87
|
+
/** False on the server or without the Web Speech API. */
|
|
88
|
+
supported: boolean;
|
|
89
|
+
/** Whether recognition is running. */
|
|
90
|
+
listening: Accessor<boolean>;
|
|
91
|
+
/** Accumulated final transcript. */
|
|
92
|
+
transcript: Accessor<string>;
|
|
93
|
+
/** Latest interim transcript (empty when the last result was final). */
|
|
94
|
+
interim: Accessor<string>;
|
|
95
|
+
/** The last error, if any. */
|
|
96
|
+
error: Accessor<Error | null>;
|
|
97
|
+
/** Start listening (call from a user gesture). */
|
|
98
|
+
start: () => void;
|
|
99
|
+
/** Stop listening. */
|
|
100
|
+
stop: () => void;
|
|
101
|
+
/** Clear the accumulated transcript. */
|
|
102
|
+
reset: () => void;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Speech-to-text via the Web Speech API (`SpeechRecognition` with
|
|
106
|
+
* `webkitSpeechRecognition` fallback).
|
|
107
|
+
*
|
|
108
|
+
* ```ts
|
|
109
|
+
* const speech = createSpeech({ lang: "en-US" });
|
|
110
|
+
* // in a click handler:
|
|
111
|
+
* speech.start();
|
|
112
|
+
* <p>{speech.transcript()}<span class="dim">{speech.interim()}</span></p>
|
|
113
|
+
* ```
|
|
114
|
+
*
|
|
115
|
+
* SSR-safe: `supported` is false without the API and `start()` is a
|
|
116
|
+
* no-op there. Note the API itself is a browser/cloud service with
|
|
117
|
+
* per-browser availability and limits.
|
|
118
|
+
*/
|
|
119
|
+
export declare function createSpeech(options?: SpeechOptions): SpeechControls;
|
|
120
|
+
export interface WaveformOptions {
|
|
121
|
+
/** Analyser node to draw (wire to `createMicLevel().analyser`). */
|
|
122
|
+
analyser: () => AnalyserNode | null;
|
|
123
|
+
/** Stroke color. Default "#3e6c99". */
|
|
124
|
+
color?: string;
|
|
125
|
+
/** Line width in px. Default 2. */
|
|
126
|
+
lineWidth?: number;
|
|
127
|
+
/** `"line"` draws the time-domain wave, `"bars"` the spectrum. Default `"line"`. */
|
|
128
|
+
mode?: "line" | "bars";
|
|
129
|
+
/** Background fill, or null for a transparent clear. Default null. */
|
|
130
|
+
background?: string | null;
|
|
131
|
+
/** Pause rendering. Default true. */
|
|
132
|
+
enabled?: boolean | Accessor<boolean>;
|
|
133
|
+
}
|
|
134
|
+
export interface WaveformControls {
|
|
135
|
+
/** Whether the render loop is running. */
|
|
136
|
+
active: Accessor<boolean>;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A canvas waveform renderer driven by an `AnalyserNode`.
|
|
140
|
+
*
|
|
141
|
+
* ```tsx
|
|
142
|
+
* const mic = createMicLevel();
|
|
143
|
+
* createWaveform(() => canvas, { analyser: mic.analyser, mode: "bars" });
|
|
144
|
+
* <canvas ref={canvas} style={{ width: "100%", height: "64px" }} />
|
|
145
|
+
* ```
|
|
146
|
+
*
|
|
147
|
+
* The canvas is sized to its CSS box times the device pixel ratio.
|
|
148
|
+
* Under reduced motion it redraws at most every 250ms. SSR-safe:
|
|
149
|
+
* nothing renders without a canvas.
|
|
150
|
+
*/
|
|
151
|
+
export declare function createWaveform(canvas: MaybeElement, options: WaveformOptions): WaveformControls;
|
|
152
|
+
/** Cloud TTS upgrade: implement `speak` with your provider. */
|
|
153
|
+
export interface TTSProvider {
|
|
154
|
+
speak: (text: string, opts: {
|
|
155
|
+
signal: AbortSignal;
|
|
156
|
+
}) => Promise<void>;
|
|
157
|
+
}
|
|
158
|
+
export interface TTSOptions {
|
|
159
|
+
/**
|
|
160
|
+
* `"browser"` uses `speechSynthesis`; pass a `TTSProvider` to
|
|
161
|
+
* upgrade to a cloud voice (fetch the audio in `speak`).
|
|
162
|
+
* Default `"browser"`.
|
|
163
|
+
*/
|
|
164
|
+
provider?: "browser" | TTSProvider;
|
|
165
|
+
/** BCP-47 language tag. Default the browser language. */
|
|
166
|
+
lang?: string;
|
|
167
|
+
/** 0.1..10. Default 1. */
|
|
168
|
+
rate?: number;
|
|
169
|
+
/** 0..2. Default 1. */
|
|
170
|
+
pitch?: number;
|
|
171
|
+
/** 0..1. Default 1. */
|
|
172
|
+
volume?: number;
|
|
173
|
+
/**
|
|
174
|
+
* Pick the voice: a voice name, or a function choosing from the
|
|
175
|
+
* available voices.
|
|
176
|
+
*/
|
|
177
|
+
voice?: string | ((voices: SpeechSynthesisVoice[]) => SpeechSynthesisVoice | undefined);
|
|
178
|
+
onStart?: () => void;
|
|
179
|
+
onEnd?: () => void;
|
|
180
|
+
onError?: (error: Error) => void;
|
|
181
|
+
}
|
|
182
|
+
export interface TTSControls {
|
|
183
|
+
/** False on the server or without `speechSynthesis` (browser provider). */
|
|
184
|
+
supported: boolean;
|
|
185
|
+
/** Whether speech is currently playing. */
|
|
186
|
+
speaking: Accessor<boolean>;
|
|
187
|
+
/** Available browser voices (loads asynchronously). */
|
|
188
|
+
voices: Accessor<SpeechSynthesisVoice[]>;
|
|
189
|
+
/** Speak the text (cancels anything currently playing). */
|
|
190
|
+
speak: (text: string) => void;
|
|
191
|
+
/** Stop playback. */
|
|
192
|
+
cancel: () => void;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Text-to-speech with `speechSynthesis` by default and a clean
|
|
196
|
+
* upgrade path to cloud voices.
|
|
197
|
+
*
|
|
198
|
+
* ```ts
|
|
199
|
+
* const tts = createTTS({ rate: 1.05 });
|
|
200
|
+
* tts.speak("Your report is ready.");
|
|
201
|
+
*
|
|
202
|
+
* // Cloud upgrade:
|
|
203
|
+
* const tts = createTTS({
|
|
204
|
+
* provider: {
|
|
205
|
+
* speak: async (text, { signal }) => {
|
|
206
|
+
* const res = await fetch("/api/tts", {
|
|
207
|
+
* method: "POST",
|
|
208
|
+
* body: JSON.stringify({ text }),
|
|
209
|
+
* signal,
|
|
210
|
+
* });
|
|
211
|
+
* await playAudioBlob(await res.blob(), signal);
|
|
212
|
+
* },
|
|
213
|
+
* },
|
|
214
|
+
* });
|
|
215
|
+
* ```
|
|
216
|
+
*
|
|
217
|
+
* SSR-safe: `supported` is false without `speechSynthesis` and
|
|
218
|
+
* `speak()` is a no-op there.
|
|
219
|
+
*/
|
|
220
|
+
export declare function createTTS(options?: TTSOptions): TTSControls;
|
|
221
|
+
export interface ThinkingOptions {
|
|
222
|
+
/** Phrases to cycle through. Default `["Thinking"]`. */
|
|
223
|
+
phrases?: string[];
|
|
224
|
+
/** Time per step in ms. Default 400. */
|
|
225
|
+
interval?: number;
|
|
226
|
+
}
|
|
227
|
+
export interface ThinkingControls {
|
|
228
|
+
/** Current label, e.g. `"Thinking.."`. Empty when stopped. */
|
|
229
|
+
text: Accessor<string>;
|
|
230
|
+
/** Whether the indicator is running. */
|
|
231
|
+
running: Accessor<boolean>;
|
|
232
|
+
/** Start cycling. */
|
|
233
|
+
start: () => void;
|
|
234
|
+
/** Stop and clear the label. */
|
|
235
|
+
stop: () => void;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* An animated "thinking" indicator for voice/AI latency: cycles
|
|
239
|
+
* trailing dots, then moves through phrases.
|
|
240
|
+
*
|
|
241
|
+
* ```ts
|
|
242
|
+
* const thinking = createThinking({ phrases: ["Thinking", "Searching"] });
|
|
243
|
+
* thinking.start();
|
|
244
|
+
* <p>{thinking.text()}</p> // "Thinking", "Thinking.", "Thinking..", ...
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export declare function createThinking(options?: ThinkingOptions): ThinkingControls;
|
|
248
|
+
export interface PromptOptions {
|
|
249
|
+
/** Called with the submitted text. */
|
|
250
|
+
onSubmit?: (text: string) => void;
|
|
251
|
+
/** Clear the input after submit. Default true. */
|
|
252
|
+
clearOnSubmit?: boolean;
|
|
253
|
+
/** Language for dictation. */
|
|
254
|
+
lang?: string;
|
|
255
|
+
}
|
|
256
|
+
export interface PromptControls {
|
|
257
|
+
/** Current input text (typed or dictated). */
|
|
258
|
+
value: Accessor<string>;
|
|
259
|
+
/** Set the input text. */
|
|
260
|
+
setValue: (value: string) => void;
|
|
261
|
+
/** Whether the mic is listening. */
|
|
262
|
+
listening: Accessor<boolean>;
|
|
263
|
+
/** Latest interim dictation. */
|
|
264
|
+
interim: Accessor<string>;
|
|
265
|
+
/** False on the server or without the Web Speech API. */
|
|
266
|
+
supported: boolean;
|
|
267
|
+
/** Toggle mic dictation. */
|
|
268
|
+
toggleMic: () => void;
|
|
269
|
+
/** Submit the current text. */
|
|
270
|
+
submit: () => void;
|
|
271
|
+
/** Clear the input. */
|
|
272
|
+
clear: () => void;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* A voice-enabled prompt input: a text value plus mic dictation,
|
|
276
|
+
* built for chat boxes and voice assistants. Dictated finals are
|
|
277
|
+
* appended to the value as they arrive; pairs with `createChatModel`
|
|
278
|
+
* for a full voice loop.
|
|
279
|
+
*
|
|
280
|
+
* ```tsx
|
|
281
|
+
* const prompt = createPrompt({ onSubmit: (t) => chat.send(t) });
|
|
282
|
+
* <input
|
|
283
|
+
* value={prompt.value()}
|
|
284
|
+
* onInput={(e) => prompt.setValue(e.currentTarget.value)}
|
|
285
|
+
* onKeyDown={(e) => e.key === "Enter" && prompt.submit()}
|
|
286
|
+
* placeholder={prompt.listening() ? prompt.interim() || "Listening..." : "Ask anything"}
|
|
287
|
+
* />
|
|
288
|
+
* <button onClick={() => prompt.toggleMic()}>Mic</button>
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
export declare function createPrompt(options?: PromptOptions): PromptControls;
|
|
292
|
+
export {};
|
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/AI 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