@solidrt/core 0.0.11 → 0.0.14
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/AGENTS.md +27 -5
- package/jsx-runtime.d.ts +3 -0
- package/package.json +9 -6
- package/src/camera.ts +83 -40
- package/src/color.ts +58 -0
- package/src/core.ts +24 -18
- package/src/gpu.ts +51 -30
- package/src/image.ts +19 -0
- package/src/index.ts +59 -4
- package/src/microphone.ts +43 -25
- package/src/renderer.ts +82 -35
- package/src/scroll.ts +93 -0
- package/src/speech-recognition.ts +149 -0
- package/src/text-input.ts +207 -0
- package/src/types.d.ts +40 -62
- package/src/window.ts +88 -8
- package/src/camera-view.tsx +0 -55
- package/src/speech.ts +0 -67
package/src/microphone.ts
CHANGED
|
@@ -1,40 +1,58 @@
|
|
|
1
|
-
// Microphone capture. A session delivers raw mono
|
|
2
|
-
// requested sample rate (the device format is converted
|
|
3
|
-
// drains whatever was captured since the last call. Captured
|
|
4
|
-
// until read, so poll read() regularly (e.g. once per frame) while
|
|
1
|
+
// Microphone capture, reactive (SolidJS) layer. A session delivers raw mono
|
|
2
|
+
// float32 samples at the requested sample rate (the device format is converted
|
|
3
|
+
// by SDL); read() drains whatever was captured since the last call. Captured
|
|
4
|
+
// audio buffers until read, so poll read() regularly (e.g. once per frame) while
|
|
5
|
+
// open.
|
|
6
|
+
//
|
|
7
|
+
// The imperative primitive lives in the `flux:microphone` module; import
|
|
8
|
+
// { open, listMicrophones } from "flux:microphone" for non-reactive use.
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
name: string
|
|
9
|
-
}
|
|
10
|
+
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
11
|
+
import { open } from "flux:microphone"
|
|
10
12
|
|
|
11
13
|
export type MicrophoneOptions = {
|
|
12
|
-
/** Explicit device id from listMicrophones(); default is the system default
|
|
14
|
+
/** Explicit device id from flux:microphone listMicrophones(); default is the system default. */
|
|
13
15
|
microphone?: number
|
|
14
16
|
/** Sample rate of the delivered samples (the device rate is converted). Default 16000. */
|
|
15
17
|
sampleRate?: number
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** A live microphone with reactive lifecycle. */
|
|
21
|
+
export type MicrophoneStream = {
|
|
22
|
+
/** Sample rate of read() samples (0 if opening failed). */
|
|
23
|
+
sampleRate(): number
|
|
24
|
+
/** Drain the mono float32 samples captured since the last read (empty if not open). */
|
|
22
25
|
read(): Float32Array
|
|
23
|
-
/**
|
|
24
|
-
|
|
26
|
+
/** Set if opening failed. */
|
|
27
|
+
error(): Error | undefined
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Opens a microphone and owns its lifecycle: closes when the reactive owner is
|
|
32
|
+
* disposed. Capture stays pull-based, so read() drains samples on demand (e.g.
|
|
33
|
+
* once per frame). For imperative use, call open() from "flux:microphone".
|
|
34
|
+
*/
|
|
35
|
+
export function createMicrophone(options: MicrophoneOptions = {}): MicrophoneStream {
|
|
36
|
+
let [error, setError] = createSignal<Error | undefined>(undefined)
|
|
37
|
+
let session: ReturnType<typeof open> | undefined
|
|
38
|
+
let rate = 0
|
|
39
|
+
try {
|
|
40
|
+
session = open(options)
|
|
41
|
+
rate = session.sampleRate
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError(e instanceof Error ? e : new Error(String(e)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onCleanup(() => {
|
|
47
|
+
if (session) {
|
|
48
|
+
session.close()
|
|
49
|
+
session = undefined
|
|
50
|
+
}
|
|
51
|
+
})
|
|
30
52
|
|
|
31
|
-
// Async to leave room for an OS permission prompt on platforms that need one
|
|
32
|
-
// (the desktop backends open synchronously).
|
|
33
|
-
export async function openMicrophone(options: MicrophoneOptions = {}): Promise<Microphone> {
|
|
34
|
-
let opened = microphone.open(options)
|
|
35
53
|
return {
|
|
36
|
-
sampleRate:
|
|
37
|
-
read: () =>
|
|
38
|
-
|
|
54
|
+
sampleRate: () => rate,
|
|
55
|
+
read: () => (session ? session.read() : new Float32Array(0)),
|
|
56
|
+
error,
|
|
39
57
|
}
|
|
40
58
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createRoot,
|
|
1
|
+
import { createRoot, onCleanup } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
|
+
import * as tree from "flux:rendertree"
|
|
3
4
|
import { attachWindow } from "./window"
|
|
4
|
-
import {
|
|
5
|
+
import { setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
|
|
6
|
+
import { parseColor, isGradient } from "./color"
|
|
5
7
|
|
|
6
8
|
export { getEventHandler } from "./core"
|
|
7
9
|
|
|
@@ -28,6 +30,34 @@ function createProxyNode(elementType: ElementType): ProxyNode {
|
|
|
28
30
|
return node
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
// Detaches `node` from `parent` and destroys it (and all descendants) on both
|
|
34
|
+
// the JS and native sides. Hoisted out of the renderer config so createPortal
|
|
35
|
+
// can reuse it (createRenderer does not return its removeNode hook).
|
|
36
|
+
function removeNode(parent: ProxyNode, node: ProxyNode): void {
|
|
37
|
+
if (!node || !parent) return
|
|
38
|
+
|
|
39
|
+
// console.debug("[srt] removeNode", parent.id, node.id)
|
|
40
|
+
|
|
41
|
+
// Update JS tree references
|
|
42
|
+
let index = parent.children.indexOf(node)
|
|
43
|
+
if (index !== -1) {
|
|
44
|
+
parent.children.splice(index, 1)
|
|
45
|
+
}
|
|
46
|
+
node.parent = undefined
|
|
47
|
+
|
|
48
|
+
tree.deleteNode(parent.id, node.id)
|
|
49
|
+
|
|
50
|
+
// Recursively clean up node and all descendants. Clear focus before
|
|
51
|
+
// dropping handlers so onBlur still fires for a focused descendant.
|
|
52
|
+
let cleanup = (n: ProxyNode) => {
|
|
53
|
+
for (let child of n.children) cleanup(child)
|
|
54
|
+
if (n.id === getFocusedNodeId()) setFocus(null)
|
|
55
|
+
nodes.delete(n.id)
|
|
56
|
+
cleanupNodeHandlers(n.id)
|
|
57
|
+
}
|
|
58
|
+
cleanup(node)
|
|
59
|
+
}
|
|
60
|
+
|
|
31
61
|
export let {
|
|
32
62
|
effect,
|
|
33
63
|
memo,
|
|
@@ -47,8 +77,8 @@ export let {
|
|
|
47
77
|
|
|
48
78
|
// console.debug("[srt] createElement", proxy.id, elementType)
|
|
49
79
|
|
|
50
|
-
if (elementType === "window")
|
|
51
|
-
else
|
|
80
|
+
if (elementType === "window") tree.createRoot(proxy.id)
|
|
81
|
+
else tree.createNode(proxy.id, elementType)
|
|
52
82
|
|
|
53
83
|
return proxy
|
|
54
84
|
},
|
|
@@ -56,14 +86,14 @@ export let {
|
|
|
56
86
|
createTextNode: (value: string): ProxyNode => {
|
|
57
87
|
let proxy = createProxyNode("d-span")
|
|
58
88
|
// console.debug("[srt] createTextNode", proxy.id, value)
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
tree.createNode(proxy.id, "d-span")
|
|
90
|
+
tree.setProperty(proxy.id, "text", "" + value)
|
|
61
91
|
return proxy
|
|
62
92
|
},
|
|
63
93
|
|
|
64
94
|
replaceText: (node: ProxyNode, value: string): void => {
|
|
65
95
|
// console.debug("[srt] replaceText", node.id, value)
|
|
66
|
-
|
|
96
|
+
tree.setProperty(node.id, "text", "" + value)
|
|
67
97
|
},
|
|
68
98
|
|
|
69
99
|
isTextNode: (node: ProxyNode): boolean => node?.elementType === "d-span",
|
|
@@ -77,12 +107,17 @@ export let {
|
|
|
77
107
|
return
|
|
78
108
|
}
|
|
79
109
|
|
|
110
|
+
if (name === "color" && isGradient(value)) {
|
|
111
|
+
tree.setProperty(node.id, name, value)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
80
115
|
if (name === "color" && typeof value === "string") {
|
|
81
|
-
|
|
116
|
+
tree.setProperty(node.id, name, parseColor(value))
|
|
82
117
|
return
|
|
83
118
|
}
|
|
84
119
|
|
|
85
|
-
|
|
120
|
+
tree.setProperty(node.id, name, value)
|
|
86
121
|
},
|
|
87
122
|
|
|
88
123
|
insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
|
|
@@ -104,35 +139,12 @@ export let {
|
|
|
104
139
|
|
|
105
140
|
// console.debug("[srt] insertNode", parent.id, node.id, anchor?.id ?? "")
|
|
106
141
|
|
|
107
|
-
if (anchor)
|
|
108
|
-
else
|
|
142
|
+
if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
|
|
143
|
+
else tree.insertNode(parent.id, node.id)
|
|
109
144
|
}
|
|
110
145
|
},
|
|
111
146
|
|
|
112
|
-
removeNode
|
|
113
|
-
if (!node || !parent) return
|
|
114
|
-
|
|
115
|
-
// console.debug("[srt] removeNode", parent.id, node.id)
|
|
116
|
-
|
|
117
|
-
// Update JS tree references
|
|
118
|
-
let index = parent.children.indexOf(node)
|
|
119
|
-
if (index !== -1) {
|
|
120
|
-
parent.children.splice(index, 1)
|
|
121
|
-
}
|
|
122
|
-
node.parent = undefined
|
|
123
|
-
|
|
124
|
-
ffi.deleteNode(parent.id, node.id)
|
|
125
|
-
|
|
126
|
-
// Recursively clean up node and all descendants. Clear focus before
|
|
127
|
-
// dropping handlers so onBlur still fires for a focused descendant.
|
|
128
|
-
let cleanup = (n: ProxyNode) => {
|
|
129
|
-
for (let child of n.children) cleanup(child)
|
|
130
|
-
if (n.id === getFocusedNodeId()) setFocus(null)
|
|
131
|
-
nodes.delete(n.id)
|
|
132
|
-
cleanupNodeHandlers(n.id)
|
|
133
|
-
}
|
|
134
|
-
cleanup(node)
|
|
135
|
-
},
|
|
147
|
+
removeNode,
|
|
136
148
|
|
|
137
149
|
getParentNode: (node: ProxyNode) => node?.parent,
|
|
138
150
|
getFirstChild: (node: ProxyNode) => node?.children[0],
|
|
@@ -145,13 +157,48 @@ export let {
|
|
|
145
157
|
},
|
|
146
158
|
})
|
|
147
159
|
|
|
160
|
+
// The app's single <window> node, set by render(). Serves as the default mount
|
|
161
|
+
// target for createPortal (single window by design, so one ambient ref).
|
|
162
|
+
let windowRoot: ProxyNode | undefined
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
|
|
166
|
+
* The element returned by `code` MUST be a `<window>` (it becomes the native
|
|
167
|
+
* window and root of the render tree); anything else throws. Runs inside a
|
|
168
|
+
* reactive root, so the whole tree is disposed together on engine reload.
|
|
169
|
+
*/
|
|
148
170
|
export function render(code: () => any) {
|
|
149
171
|
createRoot(() => {
|
|
150
172
|
let root = code()
|
|
151
173
|
if (!root || root.elementType !== "window") {
|
|
152
174
|
throw new Error("render() root must be a <window> element")
|
|
153
175
|
}
|
|
176
|
+
windowRoot = root
|
|
154
177
|
attachWindow(root.id)
|
|
155
178
|
insert(null, root)
|
|
156
179
|
})
|
|
157
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Relocates an already-built node out of its lexical position to `mount` (the
|
|
184
|
+
* window root by default), then removes it again when the surrounding reactive
|
|
185
|
+
* scope disposes. The low-level portal primitive: it moves a single node and
|
|
186
|
+
* nothing more. Conveniences (an overlay layer, centering, a backdrop) belong
|
|
187
|
+
* in higher packages built on top of it.
|
|
188
|
+
*
|
|
189
|
+
* `node` is a concrete node, not an accessor: its own children (including any
|
|
190
|
+
* reactive content) are already wired by the JSX that built it and keep working
|
|
191
|
+
* wherever it is mounted. We only move the root.
|
|
192
|
+
*
|
|
193
|
+
* The default mount is the window's flex root, so a portaled node that is not
|
|
194
|
+
* `position: "absolute"` will take flow space and displace app content. Position
|
|
195
|
+
* the portal root absolutely, or pass a `mount` target that does it for you.
|
|
196
|
+
*/
|
|
197
|
+
export function createPortal(node: ProxyNode, mount?: ProxyNode): void {
|
|
198
|
+
let target = mount ?? windowRoot
|
|
199
|
+
if (!target) {
|
|
200
|
+
throw new Error("createPortal: no mount target (called before render()?)")
|
|
201
|
+
}
|
|
202
|
+
insertNode(target, node)
|
|
203
|
+
onCleanup(() => removeNode(target, node))
|
|
204
|
+
}
|
package/src/scroll.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Headless scroll mechanism. This primitive owns the objective part of a
|
|
2
|
+
// scrollable region -- the offset and its clamping against the measured content
|
|
3
|
+
// and viewport sizes -- and nothing with a UI opinion. Wheel/drag input,
|
|
4
|
+
// momentum, scrollbars and styling are policy and belong to the component (the
|
|
5
|
+
// "skin") that composes this, the same way createCaretScroll backs TextInput.
|
|
6
|
+
|
|
7
|
+
import { createSignal, flush } from "@solidjs/signals"
|
|
8
|
+
import { getBoundingBox } from "./core"
|
|
9
|
+
import { onLayout } from "./window"
|
|
10
|
+
|
|
11
|
+
export type ScrollAxis = "vertical" | "horizontal" | "both"
|
|
12
|
+
|
|
13
|
+
export type ScrollOffset = { x: number; y: number }
|
|
14
|
+
|
|
15
|
+
export type ScrollOptions = {
|
|
16
|
+
/** Which axes can scroll. Locked axes are pinned to 0. Default "vertical". */
|
|
17
|
+
axis?: ScrollAxis
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Scroll = {
|
|
21
|
+
/** Current clamped offset, as a reactive accessor. */
|
|
22
|
+
offset(): ScrollOffset
|
|
23
|
+
/** Scroll by a delta (positive moves content up/left), clamped to range. */
|
|
24
|
+
scrollBy(dx: number, dy: number): void
|
|
25
|
+
/** Scroll to an absolute offset, clamped to range. */
|
|
26
|
+
scrollTo(x: number, y: number): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the scroll offset for a viewport node given its content node. The
|
|
31
|
+
* offset is retained between frames and re-clamped in onLayout against the
|
|
32
|
+
* current content-vs-viewport overflow, so the view stays valid when content
|
|
33
|
+
* grows or shrinks (e.g. an offset that scrolled to the bottom snaps up when the
|
|
34
|
+
* list gets shorter). scrollBy/scrollTo clamp against the most recently measured
|
|
35
|
+
* range. Pure geometry: no input handling and no visual policy.
|
|
36
|
+
*
|
|
37
|
+
* The viewport node is the clipping box (overflow hidden); the content node is
|
|
38
|
+
* the inner wrapper that holds the children and takes their natural size. Apply
|
|
39
|
+
* the returned offset to the viewport's scrollX/scrollY.
|
|
40
|
+
*/
|
|
41
|
+
export function createScroll(
|
|
42
|
+
viewport: () => { id: number } | undefined,
|
|
43
|
+
content: () => { id: number } | undefined,
|
|
44
|
+
options: ScrollOptions = {},
|
|
45
|
+
): Scroll {
|
|
46
|
+
let axis = options.axis ?? "vertical"
|
|
47
|
+
let canX = axis === "horizontal" || axis === "both"
|
|
48
|
+
let canY = axis === "vertical" || axis === "both"
|
|
49
|
+
|
|
50
|
+
let [offset, setOffset] = createSignal<ScrollOffset>({ x: 0, y: 0 })
|
|
51
|
+
|
|
52
|
+
// Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
|
|
53
|
+
// against these between layouts; onLayout re-clamps once new sizes are known.
|
|
54
|
+
let maxX = 0
|
|
55
|
+
let maxY = 0
|
|
56
|
+
|
|
57
|
+
let clamp = (x: number, y: number): ScrollOffset => ({
|
|
58
|
+
x: canX ? Math.max(0, Math.min(x, maxX)) : 0,
|
|
59
|
+
y: canY ? Math.max(0, Math.min(y, maxY)) : 0,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
let set = (x: number, y: number) => {
|
|
63
|
+
let cur = offset()
|
|
64
|
+
let next = clamp(x, y)
|
|
65
|
+
if (next.x !== cur.x || next.y !== cur.y) setOffset(next)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onLayout(() => {
|
|
69
|
+
let vp = viewport()
|
|
70
|
+
let ct = content()
|
|
71
|
+
if (!vp || !ct) return
|
|
72
|
+
let vb = getBoundingBox(vp)
|
|
73
|
+
let cb = getBoundingBox(ct)
|
|
74
|
+
if (!vb || !cb) return
|
|
75
|
+
maxX = Math.max(0, cb.width - vb.width)
|
|
76
|
+
maxY = Math.max(0, cb.height - vb.height)
|
|
77
|
+
let cur = offset()
|
|
78
|
+
let next = clamp(cur.x, cur.y)
|
|
79
|
+
if (next.x !== cur.x || next.y !== cur.y) {
|
|
80
|
+
setOffset(next)
|
|
81
|
+
flush()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
offset,
|
|
87
|
+
scrollBy: (dx, dy) => {
|
|
88
|
+
let cur = offset()
|
|
89
|
+
set(cur.x + dx, cur.y + dy)
|
|
90
|
+
},
|
|
91
|
+
scrollTo: (x, y) => set(x, y),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Speech recognition. A session captures the microphone, segments utterances
|
|
2
|
+
// by silence (Silero VAD) and transcribes each one with Whisper, delivering
|
|
3
|
+
// final transcripts through onResult. With wakeWord the session starts
|
|
4
|
+
// asleep behind an efficient wake word detector (livekit-wakeword) and only
|
|
5
|
+
// transcribes after the wake word. startRecognition resolves once the models
|
|
6
|
+
// are loaded and listening has begun; it rejects when loading fails.
|
|
7
|
+
// Models are passed as bytes so any source composes: flux:fs file(), fetch
|
|
8
|
+
// (incl. the dev-server file proxy), or a download cache layered on top.
|
|
9
|
+
// Requires a runtime built with speech support.
|
|
10
|
+
|
|
11
|
+
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
12
|
+
|
|
13
|
+
export type SpeechOptions = {
|
|
14
|
+
/** A ggml Whisper model (file contents, e.g. ggml-tiny.en.bin). */
|
|
15
|
+
model: Uint8Array
|
|
16
|
+
/** A ggml Silero VAD model (file contents). */
|
|
17
|
+
vadModel: Uint8Array
|
|
18
|
+
/** Whisper language code; "auto" detects (multilingual models only). Default "en". */
|
|
19
|
+
lang?: string
|
|
20
|
+
/** Explicit microphone device id from listMicrophones(). */
|
|
21
|
+
microphone?: number
|
|
22
|
+
/**
|
|
23
|
+
* Keep transcribing utterance after utterance. Default true. Set false to
|
|
24
|
+
* stop after the first final result (with wakeWord: re-arm instead, one
|
|
25
|
+
* result per wake). Inverse of the Web Speech API default (false there).
|
|
26
|
+
*/
|
|
27
|
+
continuous?: boolean
|
|
28
|
+
/** Also deliver snapshot transcripts (final: false) while an utterance is still being spoken. */
|
|
29
|
+
interimResults?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Wake word: start asleep, fire onWake when it is heard, then transcribe
|
|
32
|
+
* the speech that follows. How the wake word is specified depends on the
|
|
33
|
+
* engine. The current engine detects with a trained classifier and takes
|
|
34
|
+
* the model's bytes (livekit-wakeword ONNX, e.g. the pretrained "hey
|
|
35
|
+
* livekit"; custom phrases are trained offline with its toolkit). Phrase
|
|
36
|
+
* strings are reserved for engines that match text; passing them to this
|
|
37
|
+
* engine rejects with an error.
|
|
38
|
+
*/
|
|
39
|
+
wakeWord?: Uint8Array | string | string[]
|
|
40
|
+
/** Detector confidence (0..1) that counts as a wake. Default 0.5. */
|
|
41
|
+
wakeThreshold?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SpeechResult = {
|
|
45
|
+
/** Transcript of the utterance (a snapshot of it when isFinal is false). */
|
|
46
|
+
transcript: string
|
|
47
|
+
/** True for the completed utterance, false for interim snapshots. */
|
|
48
|
+
isFinal: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SpeechSession = {
|
|
52
|
+
/** Receive transcripts (replaces any previous callback). */
|
|
53
|
+
onResult(callback: (result: SpeechResult) => void): void
|
|
54
|
+
/** The user started speaking (replaces any previous callback). */
|
|
55
|
+
onSpeechStart(callback: () => void): void
|
|
56
|
+
/** The utterance ended; its final result follows once transcribed. */
|
|
57
|
+
onSpeechEnd(callback: () => void): void
|
|
58
|
+
/** The wake word was heard (wakeWordModel sessions only). */
|
|
59
|
+
onWake(callback: () => void): void
|
|
60
|
+
/** Release the microphone and discard any utterance in progress. */
|
|
61
|
+
stop(): void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function startRecognition(options: SpeechOptions): Promise<SpeechSession> {
|
|
65
|
+
let started = await speech.start(options)
|
|
66
|
+
return {
|
|
67
|
+
onResult: (callback: (result: SpeechResult) => void) => speech.setResultCallback(started.handle, callback),
|
|
68
|
+
onSpeechStart: (callback: () => void) => speech.setSpeechStartCallback(started.handle, callback),
|
|
69
|
+
onSpeechEnd: (callback: () => void) => speech.setSpeechEndCallback(started.handle, callback),
|
|
70
|
+
onWake: (callback: () => void) => speech.setWakeCallback(started.handle, callback),
|
|
71
|
+
stop: () => speech.stop(started.handle),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A live recognition session as reactive accessors. */
|
|
76
|
+
export type SpeechStream = {
|
|
77
|
+
/** True once the models are loaded and listening has begun. */
|
|
78
|
+
ready(): boolean
|
|
79
|
+
/** Latest transcript, "" until the first result (a snapshot while isFinal is false). */
|
|
80
|
+
transcript(): string
|
|
81
|
+
/** Whether transcript() is the completed utterance rather than an interim snapshot. */
|
|
82
|
+
isFinal(): boolean
|
|
83
|
+
/** True while the user is mid-utterance (between speech start and end). */
|
|
84
|
+
speaking(): boolean
|
|
85
|
+
/** True from when an utterance ends until its transcript arrives. */
|
|
86
|
+
transcribing(): boolean
|
|
87
|
+
/**
|
|
88
|
+
* wakeWord sessions only: true from when the wake word is heard until the
|
|
89
|
+
* following command is transcribed (the next final result), then false.
|
|
90
|
+
* Always false without a wakeWord.
|
|
91
|
+
*/
|
|
92
|
+
awake(): boolean
|
|
93
|
+
/** Set if loading or starting failed. */
|
|
94
|
+
error(): Error | undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Starts speech recognition and exposes it as reactive signals: read
|
|
99
|
+
* transcript()/isFinal() for results, speaking() and awake() for session
|
|
100
|
+
* state. Stops when the reactive owner is disposed. The lower-level
|
|
101
|
+
* startRecognition() is the imperative alternative.
|
|
102
|
+
*/
|
|
103
|
+
export function createSpeechRecognition(options: SpeechOptions): SpeechStream {
|
|
104
|
+
let [ready, setReady] = createSignal(false)
|
|
105
|
+
let [transcript, setTranscript] = createSignal("")
|
|
106
|
+
let [isFinal, setIsFinal] = createSignal(false)
|
|
107
|
+
let [speaking, setSpeaking] = createSignal(false)
|
|
108
|
+
let [transcribing, setTranscribing] = createSignal(false)
|
|
109
|
+
let [awake, setAwake] = createSignal(false)
|
|
110
|
+
let [error, setError] = createSignal<Error | undefined>(undefined)
|
|
111
|
+
let handle: number | undefined
|
|
112
|
+
let disposed = false
|
|
113
|
+
|
|
114
|
+
speech
|
|
115
|
+
.start(options)
|
|
116
|
+
.then((started) => {
|
|
117
|
+
if (disposed) {
|
|
118
|
+
speech.stop(started.handle)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
handle = started.handle
|
|
122
|
+
speech.setResultCallback(started.handle, (result) => {
|
|
123
|
+
setTranscript(result.transcript)
|
|
124
|
+
setIsFinal(result.isFinal)
|
|
125
|
+
if (result.isFinal) {
|
|
126
|
+
setTranscribing(false)
|
|
127
|
+
setAwake(false)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
speech.setSpeechStartCallback(started.handle, () => setSpeaking(true))
|
|
131
|
+
speech.setSpeechEndCallback(started.handle, () => {
|
|
132
|
+
setSpeaking(false)
|
|
133
|
+
setTranscribing(true)
|
|
134
|
+
})
|
|
135
|
+
speech.setWakeCallback(started.handle, () => setAwake(true))
|
|
136
|
+
setReady(true)
|
|
137
|
+
})
|
|
138
|
+
.catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
|
|
139
|
+
|
|
140
|
+
onCleanup(() => {
|
|
141
|
+
disposed = true
|
|
142
|
+
if (handle !== undefined) {
|
|
143
|
+
speech.stop(handle)
|
|
144
|
+
handle = undefined
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return { ready, transcript, isFinal, speaking, transcribing, awake, error }
|
|
149
|
+
}
|