@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/src/microphone.ts CHANGED
@@ -1,40 +1,58 @@
1
- // Microphone capture. A session delivers raw mono float32 samples at the
2
- // requested sample rate (the device format is converted by SDL); read()
3
- // drains whatever was captured since the last call. Captured audio buffers
4
- // until read, so poll read() regularly (e.g. once per frame) while open.
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
- export type MicrophoneInfo = {
7
- id: number
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 recording device. */
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
- export type Microphone = {
19
- /** Sample rate of read() samples. */
20
- sampleRate: number
21
- /** Drain the mono float32 samples captured since the last read. */
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
- /** Release the device. */
24
- close(): void
26
+ /** Set if opening failed. */
27
+ error(): Error | undefined
25
28
  }
26
29
 
27
- export function listMicrophones(): MicrophoneInfo[] {
28
- return microphone.listMicrophones()
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: opened.sampleRate,
37
- read: () => microphone.read(opened.handle),
38
- close: () => microphone.close(opened.handle),
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, createEffect } from "@solidjs/signals"
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 { parseColorToU32, setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
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") ffi.createRoot(proxy.id)
51
- else ffi.createNode(proxy.id, elementType)
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
- ffi.createNode(proxy.id, "d-span")
60
- ffi.setProperty(proxy.id, "text", "" + value)
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
- ffi.setProperty(node.id, "text", "" + value)
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
- ffi.setProperty(node.id, name, parseColorToU32(value))
116
+ tree.setProperty(node.id, name, parseColor(value))
82
117
  return
83
118
  }
84
119
 
85
- ffi.setProperty(node.id, name, value)
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) ffi.insertNode(parent.id, node.id, anchor.id)
108
- else ffi.insertNode(parent.id, node.id)
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: (parent: ProxyNode, node: ProxyNode): void => {
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
+ }