@alexkroman1/aai-ui 0.16.0 → 0.18.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.
Files changed (123) hide show
  1. package/README.md +6 -7
  2. package/dist/_submission-timing.d.ts +40 -0
  3. package/dist/_submission.d.ts +56 -0
  4. package/dist/_theme.d.ts +17 -0
  5. package/dist/_theme.test-d.d.ts +14 -0
  6. package/dist/{_utils-Mi-SCFA1.js → _utils-BlZBJPdj.js} +4 -5
  7. package/dist/_utils.d.ts +4 -5
  8. package/dist/agent-state-labels.d.ts +8 -3
  9. package/dist/agent-state.d.ts +4 -2
  10. package/dist/append-dictation.d.ts +23 -0
  11. package/dist/audio/dictation.d.ts +36 -0
  12. package/dist/audio/index.d.ts +4 -2
  13. package/dist/audio/index.js +44 -1
  14. package/dist/{chat-view-DIwPoAEG.js → chat-view-BwKpF0MN.js} +65 -45
  15. package/dist/{client-config-C7fQrioM.js → client-config-DcMCWmkO.js} +8 -13
  16. package/dist/client-config.d.ts +29 -10
  17. package/dist/components/_form-readiness.d.ts +2 -1
  18. package/dist/components/audio-result.d.ts +2 -0
  19. package/dist/components/bullet-list.d.ts +1 -0
  20. package/dist/components/button.d.ts +2 -2
  21. package/dist/components/button.js +1 -1
  22. package/dist/components/chat-view.js +1 -2
  23. package/dist/components/console-shell.d.ts +8 -5
  24. package/dist/components/controls.d.ts +1 -0
  25. package/dist/components/controls.js +2 -2
  26. package/dist/components/conversation-view.d.ts +1 -0
  27. package/dist/components/facts.d.ts +1 -0
  28. package/dist/components/form-fields.d.ts +31 -12
  29. package/dist/components/form-types.d.ts +48 -2
  30. package/dist/components/form.d.ts +10 -5
  31. package/dist/components/markdown.d.ts +1 -0
  32. package/dist/components/message-list.d.ts +1 -0
  33. package/dist/components/message-list.js +1 -2
  34. package/dist/components/session-controls.d.ts +7 -2
  35. package/dist/components/session-error-banner.d.ts +1 -0
  36. package/dist/components/session-state-dot.d.ts +45 -20
  37. package/dist/components/sidebar-layout.d.ts +2 -2
  38. package/dist/components/sidebar-layout.js +1 -1
  39. package/dist/components/start-screen.js +2 -2
  40. package/dist/components/tool-call-block.d.ts +3 -3
  41. package/dist/components/tool-call-block.js +1 -2
  42. package/dist/components/tool-call-row.d.ts +1 -0
  43. package/dist/components/tool-config-context.d.ts +23 -5
  44. package/dist/components/url-chips.d.ts +4 -3
  45. package/dist/components/workflow-fields.d.ts +36 -11
  46. package/dist/components/workflow-pending-note.d.ts +31 -12
  47. package/dist/components/workflow-run-error.d.ts +1 -0
  48. package/dist/components/workflow-run-panel.d.ts +1 -0
  49. package/dist/context-BF0KsI9X.js +473 -0
  50. package/dist/context.d.ts +8 -7
  51. package/dist/context.js +1 -381
  52. package/dist/default-client/assets/audio-CtBvSS3L.js +1 -0
  53. package/dist/default-client/assets/{capture-processor-CXCWxCO8.js → capture-processor-BZXisGjZ.js} +1 -1
  54. package/dist/default-client/assets/index-BkjCv4MX.css +2 -0
  55. package/dist/default-client/assets/index-DUQoaxdq.js +127 -0
  56. package/dist/default-client/assets/{playback-processor-DXHfyQD9.js → playback-processor-BHk-iXEU.js} +1 -1
  57. package/dist/default-client/index.html +2 -2
  58. package/dist/default-client.d.ts +1 -3
  59. package/dist/define-client.d.ts +32 -28
  60. package/dist/define-client.js +85 -44
  61. package/dist/dictation-transcriber.d.ts +49 -0
  62. package/dist/{eyebrow-UfmSz9yy.js → eyebrow-Bp4y1rZ9.js} +1 -1
  63. package/dist/{hooks-DN4gHYsj.js → hooks-1CoQY_Fc.js} +2 -2
  64. package/dist/hooks.js +1 -2
  65. package/dist/inbox-protocol.d.ts +14 -4
  66. package/dist/inbox.d.ts +1 -0
  67. package/dist/index.d.ts +12 -9
  68. package/dist/index.js +905 -277
  69. package/dist/internal.d.ts +1 -1
  70. package/dist/internal.js +6 -6
  71. package/dist/linked-client.d.ts +2 -0
  72. package/dist/{message-list-DHZcLhRA.js → message-list-BBHLK05X.js} +24 -13
  73. package/dist/page.d.ts +2 -0
  74. package/dist/phone.d.ts +1 -0
  75. package/dist/route-fetch.d.ts +8 -1
  76. package/dist/session/audio-setup.d.ts +1 -1
  77. package/dist/session/audio-state.d.ts +6 -6
  78. package/dist/session/index.js +1 -1
  79. package/dist/session/messages.d.ts +1 -9
  80. package/dist/session/preconnect.d.ts +1 -1
  81. package/dist/session/types.d.ts +4 -0
  82. package/dist/{session-CYdjRvbl.js → session-Rud8mmU-.js} +26 -15
  83. package/dist/stored-value.d.ts +2 -0
  84. package/dist/{tool-call-block-uXtMOUWN.js → tool-call-block-C8s6AhjS.js} +4 -4
  85. package/dist/tool-config-context-Bd-7xrfP.js +28 -0
  86. package/dist/types.d.ts +95 -6
  87. package/dist/{url-chips-oyy_Eiqh.js → url-chips-C_SHU6fq.js} +2 -2
  88. package/dist/use-client-runs.d.ts +2 -0
  89. package/dist/use-conversation-log.d.ts +2 -0
  90. package/dist/use-conversation.d.ts +24 -9
  91. package/dist/use-copy.d.ts +1 -0
  92. package/dist/use-dictation.d.ts +145 -0
  93. package/dist/use-download-url.d.ts +6 -1
  94. package/dist/use-flash.d.ts +1 -0
  95. package/dist/use-inbox.d.ts +2 -0
  96. package/dist/use-push-to-talk.d.ts +6 -1
  97. package/dist/use-route-mutation.d.ts +3 -0
  98. package/dist/use-route.d.ts +51 -7
  99. package/dist/use-run-key.d.ts +6 -1
  100. package/dist/use-session-controls.d.ts +1 -0
  101. package/dist/use-tap-to-talk.d.ts +5 -2
  102. package/dist/{use-user-transcript-uyHhzy4d.js → use-user-transcript-lOu_uMnf.js} +1 -1
  103. package/dist/use-user-transcript.d.ts +5 -1
  104. package/dist/use-workflow-form.d.ts +76 -167
  105. package/dist/use-workflow-progress.d.ts +5 -0
  106. package/dist/{use-workflow-run-DdhWmbx-.js → use-workflow-run-BTEemod0.js} +2 -2
  107. package/dist/use-workflow-run.d.ts +5 -0
  108. package/dist/use-workflow-runs.d.ts +18 -4
  109. package/dist/use-workflow-stream.d.ts +33 -7
  110. package/dist/use-workflows.d.ts +10 -2
  111. package/dist/workflow-client.d.ts +5 -0
  112. package/dist/workflow-def-types.d.ts +18 -2
  113. package/dist/workflow-field-kind.d.ts +66 -10
  114. package/dist/workflow-submission.d.ts +265 -0
  115. package/dist/worklets/capture-processor.d.ts +3 -3
  116. package/dist/worklets/capture-processor.js +2 -2
  117. package/dist/worklets/playback-processor.d.ts +3 -3
  118. package/dist/worklets/playback-processor.js +2 -2
  119. package/package.json +2 -2
  120. package/dist/default-client/assets/audio-DzwGxdv4.js +0 -1
  121. package/dist/default-client/assets/index-BOTLwsNO.js +0 -126
  122. package/dist/default-client/assets/index-_-x5JLLa.css +0 -2
  123. package/dist/tool-config-context-Es4YUzV2.js +0 -19
@@ -0,0 +1,265 @@
1
+ /**
2
+ * What a workflow SUBMISSION is, as a type: the object `useWorkflowSubmit` and
3
+ * `useWorkflowStream` both hand back. Moved out of `use-workflow-form.ts` at
4
+ * the source-length cap, on the seam `form-types.ts` is cut on — the two hooks
5
+ * and the form statechart all name these, and a type living in one of them
6
+ * would make the others import a hook to say what they return.
7
+ */
8
+ import type { UploadProgress, WorkflowOutputOf } from "@alexkroman1/aai/workflow-api";
9
+ import type { FormValues } from "./components/form-types.ts";
10
+ import type { WorkflowRun } from "./workflow-client.ts";
11
+ import type { SubmitInputOf } from "./workflow-def-types.ts";
12
+ /**
13
+ * What {@link WorkflowSubmission.upload} reports while the bytes are going.
14
+ *
15
+ * The SDK's per-request {@link UploadProgress} plus WHICH file it describes,
16
+ * because a form is allowed more than one and a bar over "the upload" would
17
+ * restart at zero partway through with nothing to say why.
18
+ *
19
+ * @sealed
20
+ * @public
21
+ */
22
+ export type UploadStatus = UploadProgress & {
23
+ /** The file being sent, by the name the picker gave it. */
24
+ name: string;
25
+ /** Which file of the submission this is, counting from 1. */
26
+ index: number;
27
+ /** How many files this submission sends in total. */
28
+ count: number;
29
+ /**
30
+ * Whether the person has parked this upload.
31
+ *
32
+ * A paused upload is not a stopped one: the windows already stored stay stored,
33
+ * `loaded` holds where it got to, and resuming sends what is missing rather than
34
+ * the file. So a bar rendering this reads "Paused at 62%", never "62% and
35
+ * frozen" — which is what a page could otherwise only guess from a number that
36
+ * stopped moving, the same ambiguity `complete` exists to remove on the run side.
37
+ */
38
+ paused: boolean;
39
+ };
40
+ /**
41
+ * The two marks a submission's wall clock is read from — press to settle,
42
+ * across the upload, the run, and the gap between them, which no server-side
43
+ * number can give: `output.elapsedMs` is the RUN's own clock and begins after
44
+ * the upload. Only the browser holds both ends.
45
+ *
46
+ * `performance.now()` marks rather than elapsed durations, and that is the
47
+ * load-bearing part: an elapsed number is stale the instant it is computed, so
48
+ * a page holding one has to re-render to keep it true. A mark lets the one
49
+ * section that prints a running clock re-render on its own interval and read
50
+ * `performance.now() - submittedAt` at render. Monotonic, so a clock
51
+ * correction cannot make a run look instant, or negative.
52
+ *
53
+ * @sealed
54
+ * @public
55
+ */
56
+ export type WorkflowSubmissionTiming = {
57
+ /** When `submit()` was called on this mount, or `undefined` before the first. */
58
+ submittedAt?: number | undefined;
59
+ /**
60
+ * When the submission settled — the run reached a terminal status, or the
61
+ * submit itself failed — measured at that moment rather than at the next
62
+ * render. `undefined` while it is in flight, and for a run this page did
63
+ * not start (there was no press to time from).
64
+ */
65
+ settledAt?: number | undefined;
66
+ };
67
+ /**
68
+ * Who can find the run again — the lifetime of the key it was recorded under.
69
+ *
70
+ * - `"tab"` — `useWorkflowSubmit`'s own `sessionStorage` key: a reload or a
71
+ * same-tab navigation finds the run; a new tab does not.
72
+ * - `"browser"` — a `localStorage` key (`key: { storage: "local" }`, or
73
+ * `useRunKey({ storage: "local" })`), or a key the page supplied itself (an
74
+ * account's id): any tab on this browser finds it, so closing the tab loses
75
+ * nothing.
76
+ * - `"none"` — nothing will find it: `recover: false`, or `useWorkflowStream`,
77
+ * whose run is reading the bytes THIS page is sending and is ended, not
78
+ * orphaned, by a reload.
79
+ *
80
+ * `<WorkflowPendingNote>` reads it to say the right sentence; the raw fact is
81
+ * published so a page with its own note can too.
82
+ *
83
+ * @public
84
+ */
85
+ export type WorkflowSubmissionScope = "tab" | "browser" | "none";
86
+ /**
87
+ * What {@link useWorkflowSubmit} returns.
88
+ *
89
+ * @see {@link WorkflowStreamSubmission} — an ALIAS of this type, returned by
90
+ * `useWorkflowStream`, which is a drop-in sibling. Exactly two fields MEAN
91
+ * something different there, and both differences follow from WHEN the run is
92
+ * created: there, `submit()` resolves when the UPLOAD finishes rather than when
93
+ * the run is accepted, and `run` is non-`undefined` from before the bytes are
94
+ * in, so a page can render `<WorkflowProgress>` beside the upload bar instead
95
+ * of after it. Here the run does not exist until the last byte lands.
96
+ *
97
+ * @sealed
98
+ */
99
+ export type WorkflowSubmission<R = unknown, I = unknown> = {
100
+ /**
101
+ * Start a run with this input. Resolves once the run EXISTS — progress
102
+ * arrives through `run` — so a `<Form>`'s handler can await it to know the
103
+ * submission was accepted.
104
+ */
105
+ submit: (input: I) => Promise<void>;
106
+ /**
107
+ * Start a run from a `<Form>`'s values, which are UNVALIDATED.
108
+ *
109
+ * The same function as {@link WorkflowSubmission.submit}, with the type the
110
+ * form path can honestly offer. `FormValues` is `Record<string, unknown>`
111
+ * scraped off the DOM at submit time — a `<TextField name="limit">`
112
+ * contributes a string whatever the schema says — so the shape is not known
113
+ * here and the SERVER is what checks it against the workflow's schema.
114
+ *
115
+ * Two doors rather than one loose one: `submit` takes the workflow's own
116
+ * input type, so a hand-built object is checked at compile time and
117
+ * `submit({ ur1: 42 })` is an error; widening it to accept `FormValues` would
118
+ * have made every object satisfy it and given the typing back. Reaching for
119
+ * this one is the author saying "these came from a form", which is a fact
120
+ * about the values and not a cast.
121
+ */
122
+ submitForm: (values: FormValues) => Promise<void>;
123
+ /** Clear the run and any error, putting the form back to its initial state. */
124
+ reset: () => void;
125
+ /**
126
+ * End the current run's `sleep()` early — "file it now" — resolving how many
127
+ * pending sleeps were interrupted.
128
+ *
129
+ * Bound to the run this submission is following, which is the point: it is
130
+ * the only reason a page holding one of these hooks needed an `api` of its
131
+ * own. `0` is an answer rather than a failure (the run had already moved past
132
+ * its wait, or there is no run yet), so nothing here has to be guarded.
133
+ */
134
+ wake: () => Promise<number>;
135
+ /**
136
+ * Stop the current run, resolving whether this call is what ended it.
137
+ *
138
+ * `false` for a run that had already finished, and for no run at all — the
139
+ * SDK's contract, because two tabs pressing Stop is ordinary. Distinct from
140
+ * `reset()`, which puts the FORM back and leaves the run running.
141
+ */
142
+ cancel: () => Promise<boolean>;
143
+ /** The run, once started, followed to completion. */
144
+ run: WorkflowRun<R> | undefined;
145
+ /**
146
+ * True from `submit()` on this mount until `reset()` — did THIS page start
147
+ * the run it is showing?
148
+ *
149
+ * A page needs it to say the right sentence and cannot derive it: a run
150
+ * ADOPTED by the mount-time lookup after a reload looks exactly like one this
151
+ * page started. Six templates kept a `useState(false)` next to this hook, set
152
+ * it in their `onSubmit` and mirrored it in their `onClear` — shadow state
153
+ * for a fact only this hook can know, since it is the thing that decides
154
+ * between `submit()` and the recovery lookup. One of the six grew a fourth
155
+ * branch and had to move the whole note into its own module with its own
156
+ * spec, which is what a seam missing one layer down looks like.
157
+ *
158
+ * The RAW fact rather than a derived "recovered", deliberately: with `run`
159
+ * these are three states, not two, and the third is the one a page most needs
160
+ * to explain. `startedHere` is "you pressed the button"; `!startedHere &&
161
+ * !run` is the mount-time lookup still going; `!startedHere && run` is a run
162
+ * this browser started earlier, now in front of somebody who did not press
163
+ * anything. A boolean meaning only the last of those cannot express the
164
+ * middle one.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * declare const submission: import("@alexkroman1/aai-ui").WorkflowSubmission;
169
+ * const note = submission.startedHere
170
+ * ? "You can close this tab or reload it — this page will find the run again."
171
+ * : submission.run === undefined
172
+ * ? "Looking for a run this tab started earlier…"
173
+ * : "Still working on the run this tab started earlier. Reloading is safe.";
174
+ * ```
175
+ */
176
+ startedHere: boolean;
177
+ /**
178
+ * True from `submit()` until the run reaches a terminal status.
179
+ *
180
+ * The WORK, not the request: a run outlives its `POST`, and a submit button
181
+ * that re-enabled on the response would invite a second submission of work
182
+ * already in flight.
183
+ */
184
+ pending: boolean;
185
+ /**
186
+ * How far the submission's files have got, while any are still going.
187
+ *
188
+ * Undefined before the first byte and again from the moment the last one
189
+ * lands, so a page can render `{upload && <UploadProgressBar upload={upload} />}`
190
+ * and the bar exists exactly for as long as there is an upload to describe. A
191
+ * form with no files never sets it at all.
192
+ *
193
+ * The wait it covers is the one `run` cannot: a run does not EXIST until its
194
+ * input is stored, so `pending` is true and there is nothing to poll — which
195
+ * for a 200 MB recording is minutes of a page that looks stuck.
196
+ */
197
+ upload: UploadStatus | undefined;
198
+ /**
199
+ * Park the upload where it is, stopping the bytes in flight.
200
+ *
201
+ * The windows already stored stay stored, so `resumeUpload()` sends what is
202
+ * missing rather than the file — which is the difference between a pause a
203
+ * person will actually use on a 200 MB recording and a cancel dressed up as one.
204
+ *
205
+ * `submit()`'s promise stays unresolved across a pause, because the submission
206
+ * genuinely has not finished: the run does not exist until the last byte lands,
207
+ * so resolving here would tell a `<Form>` the work was accepted when nothing has
208
+ * been started. A no-op when there is no upload in flight.
209
+ */
210
+ pauseUpload: () => void;
211
+ /** Continue a paused upload, sending only the windows the store does not have. */
212
+ resumeUpload: () => void;
213
+ /** The submit's own failure (a rejected input), or the watch's. */
214
+ error: string | undefined;
215
+ /**
216
+ * Press-to-settle marks for the submission THIS mount made — see
217
+ * {@link WorkflowSubmissionTiming}. Both `undefined` before the first
218
+ * `submit()` and again after `reset()`; a run adopted by the mount-time
219
+ * lookup has no press to time from and sets neither.
220
+ */
221
+ timing: WorkflowSubmissionTiming;
222
+ /**
223
+ * Who can find the run again after this page is gone — see
224
+ * {@link WorkflowSubmissionScope}. Derived from the hook's options (`key`,
225
+ * `recover`), so a page never restates it; `<WorkflowPendingNote>` reads it.
226
+ */
227
+ scope: WorkflowSubmissionScope;
228
+ };
229
+ /**
230
+ * The submission `useWorkflowSubmit<D>` / `useWorkflowStream<D>` hands back for
231
+ * the workflow def `D`, named so a component can take one as a prop.
232
+ *
233
+ * The hook's own return is inferred, so a page that renders everything in one
234
+ * function never needs this. A page that splits does, and there was no way to
235
+ * write it from the outside without three names — `WorkflowSubmission`,
236
+ * `WorkflowOutputOf` and `SubmitInputOf` — composed by hand, which is what a
237
+ * `ReturnType<typeof useWorkflowSubmit>` cannot say because the hook is
238
+ * generic. `podcast-digest-workflow`'s `client.tsx` composed exactly that.
239
+ *
240
+ * @example
241
+ * ```tsx
242
+ * // file: agent.ts
243
+ * import { agent, workflow } from "@alexkroman1/aai";
244
+ * import { z } from "zod";
245
+ *
246
+ * export const dailyDigest = workflow({
247
+ * input: z.object({ hour: z.number() }),
248
+ * output: z.object({ posted: z.string() }),
249
+ * run: async ({ hour }) => ({ posted: `daily at ${hour}:00` }),
250
+ * });
251
+ *
252
+ * export default agent({ name: "Digest", workflows: { dailyDigest } });
253
+ *
254
+ * // file: client.tsx
255
+ * import type { WorkflowSubmissionOf } from "@alexkroman1/aai-ui";
256
+ * import type { dailyDigest } from "./agent.ts";
257
+ *
258
+ * function ScheduleControls({ submission }: { submission: WorkflowSubmissionOf<typeof dailyDigest> }) {
259
+ * return submission.run?.status === "completed" ? <p>{submission.run.output.posted}</p> : null;
260
+ * }
261
+ * ```
262
+ *
263
+ * @public
264
+ */
265
+ export type WorkflowSubmissionOf<D> = WorkflowSubmission<WorkflowOutputOf<D>, SubmitInputOf<D>>;
@@ -1,4 +1,4 @@
1
1
  /** Raw worklet source — exported so tests can evaluate the processor directly. */
2
- export declare const captureProcessorSource = "\nclass CaptureProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.recording = false;\n const opts = options.processorOptions || {};\n // The context runs at the STT rate, so this is both the input and the\n // output rate \u2014 there is nothing to convert.\n this.rate = opts.sampleRate || sampleRate;\n // Int16 accumulation buffer: flushed to the main thread as one transferred\n // ArrayBuffer once ~bufferSeconds of samples are batched. Sized 2x the\n // flush target so a whole render quantum always fits before flushing.\n this.targetSamples = Math.max(1, Math.round(this.rate * (opts.bufferSeconds || 0.1)));\n this.pending = new Int16Array(this.targetSamples * 2);\n this.pendingLen = 0;\n // Dead-mic probe: samples left to inspect before concluding the device\n // delivers nothing but digital silence. Only consumed while recording, so\n // the cost disappears after the window (or after the first real sample).\n this.probeSamplesLeft = Math.round(\n (this.rate * (opts.silenceProbeMs ?? 1500)) / 1000,\n );\n this.port.onmessage = (e) => {\n if (e.data.event === 'start') this.recording = true;\n else if (e.data.event === 'stop') {\n // Final flush so the tail of speech isn't dropped on close, then ack\n // so the host knows the tail chunk (if any) has been posted and it is\n // safe to tear the context down.\n this.flush();\n this.recording = false;\n this.port.postMessage({ event: 'stopped' });\n }\n };\n }\n\n // Convert Float32 -> Int16 and append to the pending batch. Writes through\n // an Int16Array directly (assignment truncates like DataView.setInt16).\n accumulate(samples) {\n let buf = this.pending;\n if (this.pendingLen + samples.length > buf.length) {\n // Defensive: only reachable if a render quantum outproduces the 1x\n // headroom above the flush target (never with 128-sample quanta).\n const grown = new Int16Array((this.pendingLen + samples.length) * 2);\n grown.set(buf.subarray(0, this.pendingLen));\n this.pending = grown;\n buf = grown;\n }\n for (let i = 0; i < samples.length; i++) {\n const s = Math.max(-1, Math.min(1, samples[i]));\n buf[this.pendingLen++] = s < 0 ? s * 0x8000 : s * 0x7fff;\n }\n }\n\n // Post the batched samples as one transferred ArrayBuffer and reset.\n flush() {\n if (this.pendingLen === 0) return;\n const buffer = this.pending.buffer.slice(0, this.pendingLen * 2);\n this.pendingLen = 0;\n this.port.postMessage({ event: 'chunk', buffer }, [buffer]);\n }\n\n // Watch the first window of input for any nonzero sample. One is enough to\n // prove the device is live \u2014 a real mic in a quiet room still carries a\n // noise floor, so all-zeros means muted, wrong input, or no input at all.\n probeForSilence(channel) {\n for (let i = 0; i < channel.length; i++) {\n if (channel[i] !== 0) {\n this.probeSamplesLeft = 0;\n return;\n }\n }\n this.probeSamplesLeft -= channel.length;\n if (this.probeSamplesLeft <= 0) {\n this.port.postMessage({ event: 'silent' });\n }\n }\n\n process(inputs) {\n const input = inputs[0];\n if (!input || !input[0] || !this.recording) return true;\n\n if (this.probeSamplesLeft > 0) this.probeForSilence(input[0]);\n\n this.accumulate(input[0]);\n if (this.pendingLen >= this.targetSamples) this.flush();\n return true;\n }\n}\n\nregisterProcessor('capture-processor', CaptureProcessor);\n";
3
- declare const _default: string;
4
- export default _default;
2
+ export declare const captureProcessorSource: string;
3
+ declare const captureProcessorUrl: string;
4
+ export default captureProcessorUrl;
@@ -92,6 +92,6 @@ class CaptureProcessor extends AudioWorkletProcessor {
92
92
 
93
93
  registerProcessor('capture-processor', CaptureProcessor);
94
94
  `;
95
- var capture_processor_default = workletModuleUrl(captureProcessorSource);
95
+ const captureProcessorUrl = workletModuleUrl(captureProcessorSource);
96
96
  //#endregion
97
- export { captureProcessorSource, capture_processor_default as default };
97
+ export { captureProcessorSource, captureProcessorUrl as default };
@@ -1,4 +1,4 @@
1
1
  /** Raw worklet source — exported so tests can evaluate the processor directly. */
2
- export declare const playbackProcessorSource = "\nclass PlaybackProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n const opts = options.processorOptions || {};\n // The node's context IS the playback context (audio/voice-io.ts asserts its rate),\n // so the worklet-global sampleRate is authoritative; the option exists\n // for the node-less test harness.\n const rate = opts.sampleRate ?? sampleRate;\n // Kept, because every derived quantity below reads it and `bufferedMs` in\n // process() is the one that used to read the worklet global instead. Those\n // agree in production (the option is unset, so `rate` IS `sampleRate`) and\n // diverge only in the node-less harness \u2014 which is the one place a spec\n // could ever assert on the reported backlog, so the divergence made the\n // number untestable rather than wrong.\n this.rate = rate;\n // The one fill target \u2014 for the start of a turn and for recovery after an\n // underrun alike. If 'done' arrives first (short utterance), start\n // immediately instead of waiting for audio that is never coming.\n this.fillSamples = Math.floor((rate * (opts.fillMs ?? 200)) / 1000);\n // Concealment source: a ring of the most recently played samples, looped\n // under a decaying gain to cover a gap. Sized to the fade window, with a\n // per-sample decay that reaches the floor exactly at its end.\n this.concealCapacity = Math.max(1, Math.floor((rate * 40) / 1000));\n this.concealBuf = new Float32Array(this.concealCapacity);\n this.concealDecay = Math.exp(Math.log(0.001) / this.concealCapacity);\n // Float32 ring buffer \u2014 PLAYBACK_BUFFER_SECONDS at the context sample\n // rate. Allocated once for the node's lifetime; per-turn state resets via\n // resetTurn(). writePos and readPos are absolute (monotonic) sample\n // counts; the buffer is indexed modulo capacity so a longer reply keeps\n // playing instead of writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\n // Cadence for the 'progress' report in process(), counted in samples so\n // the hot path never reads a clock. Survives resetTurn(): the host clamps\n // upward only, so a stale count costs at most one extra report.\n this.reportIntervalSamples = Math.floor((rate * 500) / 1000);\n this.sinceReportSamples = 0;\n // Platform endianness probe: the wire format is PCM16 little-endian, so\n // the Int16Array fast path in ingestBytes is only valid on LE hosts\n // (every shipping browser target; the DataView path is the fallback).\n this.littleEndian = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1;\n this.resetTurn();\n\n this.port.onmessage = (e) => {\n const d = e.data;\n if (d.event === 'write') {\n this.ingestBytes(d.buffer);\n } else if (d.event === 'interrupt') {\n // Applied eagerly, not deferred to the next process(): onmessage and\n // process() never interleave (one audio thread), and a deferred\n // interrupt would let 'write'/'done' frames for the NEXT turn\n // coalesce in ahead of it and be wiped by the reset along with the\n // cancelled turn's audio.\n this.stopTurn('interrupt');\n } else if (d.event === 'done') {\n this.isDone = true;\n // Echoed back on this turn's 'stop' so the host can tell WHICH turn\n // drained: a stop already in flight when a barge-in lands would\n // otherwise settle the next turn's done() the moment it arrives.\n this.doneTurn = d.turn ?? null;\n }\n };\n }\n\n // Reset per-turn state so the node is reusable across replies without\n // reallocating the sample buffer or re-instantiating the worklet.\n resetTurn() {\n this.isDone = false;\n // Host turn id carried by this turn's 'done' (null until one arrives).\n this.doneTurn = null;\n this.playing = false;\n // Whether any real audio has been rendered this turn. Separates a turn's\n // pre-roll (nothing to extrapolate from, and not a defect) from a\n // mid-turn underrun.\n this.hasPlayed = false;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\n // Concealment ring state and the current fade position.\n this.concealLen = 0;\n this.concealWrite = 0;\n this.concealPos = 0;\n this.concealGain = 1;\n // Episode flags, so a multi-quantum gap counts as one event.\n this.concealing = false;\n this.concealedSilence = false;\n // Reported to the host on 'stop'. A fresh object per turn: the one just\n // posted must not be mutated by the next turn.\n this.stats = {\n concealedSamples: 0,\n silentConcealedSamples: 0,\n concealmentEvents: 0,\n silentConcealmentEvents: 0,\n };\n }\n\n // End the current turn: notify the host and rearm for the next reply.\n // Must NOT return false from process() \u2014 a processor that stops is dead\n // for good, forcing a new node (and buffer) per reply.\n // `reason` ('interrupt' | 'done') tells the host which turn boundary this\n // stop belongs to \u2014 interrupt-stops are dropped host-side, flush() having\n // already settled that turn \u2014 and `turn` names WHICH turn drained (the id\n // this turn's 'done' carried), so a drain-stop still in flight when a\n // barge-in lands cannot settle the next turn's done() early.\n stopTurn(reason) {\n this.port.postMessage({ event: 'stop', reason, turn: this.doneTurn, stats: this.stats });\n this.resetTurn();\n }\n\n // Cover a quantum (from `start`) where real audio should have been.\n //\n // Before the turn's first samples there is nothing to extrapolate from, so\n // the gap is plain silence and counted as nothing \u2014 WebRTC likewise only\n // counts concealment once playout has begun. After that, loop the retained\n // tail under a decaying gain: a hard zero-fill is a discontinuity mid-word,\n // which is the click that makes a brief stall sound like breakage.\n coverGap(out, start) {\n if (!this.hasPlayed) {\n out.fill(0, start);\n return;\n }\n if (!this.concealing) {\n this.concealing = true;\n this.concealedSilence = false;\n this.stats.concealmentEvents++;\n }\n const total = out.length - start;\n const len = this.concealLen;\n let silent = 0;\n // Second condition: once the fade has decayed to the floor, every sample\n // the loop would emit is 0 anyway \u2014 bulk-fill instead of running 128\n // branchy iterations per quantum for the whole tail of a long stall.\n if (len === 0 || this.concealGain < 0.001) {\n out.fill(0, start);\n silent = total;\n } else {\n let g = this.concealGain;\n for (let i = start; i < out.length; i++) {\n if (g < 0.001) {\n // The fade has run out: keep counting the gap, but stop looping a\n // fragment that is now inaudible anyway.\n out[i] = 0;\n silent++;\n continue;\n }\n out[i] = this.concealBuf[this.concealPos] * g;\n this.concealPos = this.concealPos + 1 === len ? 0 : this.concealPos + 1;\n g *= this.concealDecay;\n }\n this.concealGain = g;\n }\n this.stats.concealedSamples += total;\n if (silent > 0) {\n this.stats.silentConcealedSamples += silent;\n if (!this.concealedSilence) {\n this.concealedSilence = true;\n this.stats.silentConcealmentEvents++;\n }\n }\n }\n\n // Retain the tail of a rendered quantum as the next gap's concealment\n // source, and close any episode the real audio just ended.\n rememberTail(out, n) {\n const cap = this.concealCapacity;\n const take = Math.min(n, cap);\n // Bulk copies (this runs on every cleanly rendered quantum): the tail is\n // one contiguous source run, landing in at most two ring runs.\n const tail = out.subarray(n - take, n);\n const first = Math.min(take, cap - this.concealWrite);\n this.concealBuf.set(tail.subarray(0, first), this.concealWrite);\n if (take > first) this.concealBuf.set(tail.subarray(first), 0);\n this.concealWrite = (this.concealWrite + take) % cap;\n this.concealLen = Math.min(cap, this.concealLen + take);\n // Read the loop oldest-first; once the ring is full the write cursor is\n // the oldest retained sample.\n this.concealPos = this.concealLen === cap ? this.concealWrite : 0;\n this.concealing = false;\n this.concealGain = 1;\n }\n\n ingestBytes(uint8) {\n let bytes = uint8;\n\n if (this.carry !== null) {\n const merged = new Uint8Array(1 + bytes.length);\n merged[0] = this.carry;\n merged.set(bytes, 1);\n bytes = merged;\n this.carry = null;\n }\n\n if (bytes.length % 2 !== 0) {\n this.carry = bytes[bytes.length - 1];\n bytes = bytes.subarray(0, bytes.length - 1);\n }\n\n if (bytes.length === 0) return;\n const numSamples = bytes.length / 2;\n const cap = this.capacity;\n const samples = this.samples;\n if (this.littleEndian && (bytes.byteOffset & 1) === 0) {\n // Fast path: 2-byte-aligned LE bytes wrap directly as an Int16Array;\n // copy wrap-aware in at most two runs with no per-sample DataView call\n // or modulo. This runs on the realtime audio thread.\n const int16 = new Int16Array(bytes.buffer, bytes.byteOffset, numSamples);\n let src = 0;\n let dst = this.writePos % cap;\n while (src < numSamples) {\n const run = Math.min(numSamples - src, cap - dst);\n for (let j = 0; j < run; j++) {\n samples[dst + j] = int16[src + j] / 0x8000;\n }\n src += run;\n dst = 0;\n }\n } else {\n // Odd byte offset (or big-endian host): fall back to per-sample reads.\n const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);\n let dst = this.writePos % cap;\n for (let i = 0; i < numSamples; i++) {\n samples[dst] = view.getInt16(i * 2, true) / 0x8000;\n dst++;\n if (dst === cap) dst = 0;\n }\n }\n this.writePos += numSamples;\n // If the producer outran the consumer by more than the buffer holds, drop\n // the oldest unplayed audio rather than reading samples we've overwritten.\n if (this.writePos - this.readPos > this.capacity) {\n this.readPos = this.writePos - this.capacity;\n }\n }\n\n process(inputs, outputs) {\n // No output wired up yet \u2014 nothing to render this quantum. Throwing here\n // would permanently kill the processor (the node is persistent per\n // session), so guard like the capture processor does.\n if (!outputs[0] || !outputs[0][0]) return true;\n const out = outputs[0][0];\n const avail = this.writePos - this.readPos;\n\n // Report the unplayed backlog to the host (`playback_progress`). This IS\n // the closed-loop signal: the host otherwise models playback open-loop \u2014\n // every forwarded chunk assumed to start playing on arrival at exactly\n // 1.0x \u2014 and cannot see a buffer that has run ahead of the wall clock.\n // Measured against a client draining at 0.6x, the host declared the line\n // silent while it still held seconds of the reply, and then opened the\n // speaking-edge gate over speech the caller had not heard.\n //\n // Sent from process() rather than a timer because this is the only place\n // that sees the buffer on the audio thread; the counter is in QUANTA, so\n // it costs an integer compare per render and no clock read. Silence is not\n // reported: an empty buffer is what the host already assumes.\n this.sinceReportSamples += out.length;\n if (this.sinceReportSamples >= this.reportIntervalSamples) {\n this.sinceReportSamples = 0;\n if (avail > 0) {\n this.port.postMessage({ event: 'progress', bufferedMs: (avail / this.rate) * 1000 });\n }\n }\n\n // Filling: wait for the target. 'done' short-circuits it \u2014 what is\n // buffered is all there will be, so there is nothing left to wait for.\n if (!this.playing) {\n if (avail >= this.fillSamples || this.isDone) {\n this.playing = true;\n } else {\n this.coverGap(out, 0);\n return true;\n }\n }\n\n // Underrun: this quantum cannot be filled and more audio is still coming.\n // Go back to filling and cover the gap, leaving\n // readPos untouched \u2014 the fragment stays buffered and plays intact once\n // the buffer recovers, instead of being dribbled out a few samples at a\n // time for the rest of the turn.\n if (avail < out.length && !this.isDone) {\n this.playing = false;\n this.coverGap(out, 0);\n return true;\n }\n\n if (avail > 0) {\n const n = Math.min(avail, out.length);\n // Copy from the ring buffer, splitting across the wrap boundary.\n const start = this.readPos % this.capacity;\n const first = Math.min(n, this.capacity - start);\n out.set(this.samples.subarray(start, start + first), 0);\n if (n > first) out.set(this.samples.subarray(0, n - first), first);\n this.readPos += n;\n // Only reachable with n < out.length on the turn's final partial\n // quantum (the underrun branch above catches every other case).\n out.fill(0, n);\n this.hasPlayed = true;\n this.rememberTail(out, n);\n return true;\n }\n\n // Drained and done: end the turn. Not reachable mid-turn \u2014 an empty\n // buffer with audio still coming is the underrun branch above.\n out.fill(0);\n if (this.isDone) {\n this.stopTurn('done');\n }\n return true;\n }\n}\n\nregisterProcessor('playback-processor', PlaybackProcessor);\n";
3
- declare const _default: string;
4
- export default _default;
2
+ export declare const playbackProcessorSource: string;
3
+ declare const playbackProcessorUrl: string;
4
+ export default playbackProcessorUrl;
@@ -316,6 +316,6 @@ class PlaybackProcessor extends AudioWorkletProcessor {
316
316
 
317
317
  registerProcessor('playback-processor', PlaybackProcessor);
318
318
  `;
319
- var playback_processor_default = workletModuleUrl(playbackProcessorSource);
319
+ const playbackProcessorUrl = workletModuleUrl(playbackProcessorSource);
320
320
  //#endregion
321
- export { playback_processor_default as default, playbackProcessorSource };
321
+ export { playbackProcessorUrl as default, playbackProcessorSource };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  "use-stick-to-bottom": "^1.1.6",
47
47
  "use-sync-external-store": "^1.7.0",
48
48
  "xstate": "^5.33.2",
49
- "@alexkroman1/aai": "0.16.0"
49
+ "@alexkroman1/aai": "0.18.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@tailwindcss/vite": "^4.3.3",
@@ -1 +0,0 @@
1
- import{a as e,i as t,t as n}from"./index-BOTLwsNO.js";var r={echoCancellation:!0,noiseSuppression:!1,autoGainControl:!1,voiceIsolation:!1};function i(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}function a(e){let t=Error(`Audio ${e} worklet crashed`);return console.error(`[aai-ui]`,t.message),t}function o(e){e.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{})}function s(e,t,n){let r=new AudioWorkletNode(e,`capture-processor`,{channelCount:1,channelCountMode:`explicit`}),i=null;return r.port.onmessage=e=>{let r=e.data;r.event===`chunk`&&r.buffer?t(r.buffer):r.event===`silent`?n?.():r.event===`stopped`&&(i?.(),i=null)},{node:r,start(){r.port.postMessage({event:`start`})},stop(){let{promise:e,resolve:t}=Promise.withResolvers(),n=setTimeout(t,250);return i=()=>{clearTimeout(n),t()},r.port.postMessage({event:`stop`}),e}}}async function c(e){let{sampleRate:t,captureWorkletSrc:n,maxSeconds:a}=e,c=new AudioContext({sampleRate:t,latencyHint:`interactive`}),l=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),u;try{[u]=await Promise.all([l,c.resume(),c.audioWorklet.addModule(n)]),i(c.sampleRate,t,`capture`)}catch(e){throw o(l),await c.close().catch(()=>{}),e}let d=Math.round(t*a)*2,f=[],p=0,m=!1,h=null,g=c.createMediaStreamSource(u),_=s(c,e=>{if(h){h.onChunk(e);return}for(f.push(e),p+=e.byteLength;p>d&&f.length>1;)p-=f.shift()?.byteLength??0},()=>{h?h.onSilent?.():m=!0});g.connect(_.node),_.start();let v=()=>{let e=f;return f=[],p=0,e},y=!1,b=async()=>{y||(y=!0,g.disconnect(),_.node.disconnect(),await c.close().catch(e=>{console.warn(`AudioContext close failed:`,e)}))};return{sampleRate:t,stream:u,ctx:c,handOff(e){h=e;let t=v();return t.length>0&&e.onBuffered(t),m&&e.onSilent?.(),{mic:g,capture:_}},async detach(){return await _.stop(),await b(),{chunks:v(),sampleRate:t}},async close(){y||await _.stop();for(let e of u.getTracks())e.stop();await b()}}}function l(e){let t=new Int16Array(e.length);for(let n=0;n<e.length;n++){let r=Math.max(-1,Math.min(1,e[n]??0));t[n]=r<0?r*32768:r*32767}return t.buffer}async function u(e,t,r){let i=e.reduce((e,t)=>e+t.byteLength/2,0);if(i===0)return[];if(t===r)return e;try{let a=Math.round(i*r/t),o=new OfflineAudioContext(1,a,r),s=o.createBuffer(1,i,t),c=s.getChannelData(0),u=0;for(let t of e)for(let e of new Int16Array(t))c[u++]=e/32768;let d=o.createBufferSource();d.buffer=s,d.connect(o.destination),d.start();let f=(await o.startRendering()).getChannelData(0),p=Math.max(1,Math.round(r*n)),m=[];for(let e=0;e<f.length;e+=p)m.push(l(f.subarray(e,e+p)));return m}catch(e){return console.warn(`[aai-ui] pre-connect audio dropped: resample failed`,e),[]}}async function d(e,t,n){let r=e.createMediaStreamSource(t),i=s(e,n.onMicData,n.onMicSilent);if(r.connect(i.node),n.buffered){let{chunks:t,sampleRate:r}=n.buffered,i=await u(t,r,e.sampleRate);i.length>0&&n.onBuffered(i)}return i.start(),{mic:r,capture:i}}async function f(n){let{sttSampleRate:s,ttsSampleRate:c,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:f,onError:p,onPlaybackStats:m,onPlaybackProgress:h,onMicSilent:g,preConnect:_}=n,v=n.onPreConnectAudio??(e=>{for(let t of e)f(t)}),y=_?.sampleRate===s?_:null,b=_&&!y?await _.detach():null,x=new AudioContext({sampleRate:c,latencyHint:`playback`}),S=!y&&s===c,C=y?.ctx??(S?x:new AudioContext({sampleRate:s,latencyHint:`interactive`}));async function w(){let e=S?[x]:[x,C];await Promise.all(e.map(e=>e.close().catch(e=>{console.warn(`AudioContext close failed:`,e)})))}let T=_?Promise.resolve(_.stream):navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),E;try{[E]=await Promise.all([T,x.resume(),C.resume(),y?void 0:C.audioWorklet.addModule(l),x.audioWorklet.addModule(u)]),i(C.sampleRate,s,`capture`),i(x.sampleRate,c,`playback`)}catch(e){throw o(T),await w(),e}let{mic:D,capture:O}=y?y.handOff({onBuffered:v,onChunk:f,onSilent:g}):await d(C,E,{onMicData:f,onMicSilent:g,buffered:b,onBuffered:v});O.node.onprocessorerror=()=>p?.(a(`capture`));let k=null,A=0,j=null,M=new AbortController;function N(){let e=j;j=null,e?.settle()}function P(e){e.stats&&e.stats.concealedSamples>0&&m?.(e.stats),e.reason!==`interrupt`&&(j===null||e.turn===j.turn)&&N()}function F(){if(k)return k;let e=new AudioWorkletNode(x,`playback-processor`);return e.connect(x.destination),e.port.onmessage=e=>{e.data.event===`stop`?P(e.data):e.data.event===`progress`&&h?.(e.data.bufferedMs)},e.onprocessorerror=()=>{let e=a(`playback`);N(),p?.(e)},k=e,e}let I={enqueue(e){M.signal.aborted||e.byteLength!==0&&F().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){if(!k)return Promise.resolve();let n=++A;if(k.port.postMessage({event:`done`,turn:n}),x.state!==`running`)return N(),Promise.resolve();let{promise:r,resolve:i}=Promise.withResolvers();N();let a=()=>{clearInterval(o),clearTimeout(s),j?.settle===a&&(j=null),i()},o=setInterval(()=>{x.state!==`running`&&a()},e),s=setTimeout(a,t);return j={turn:n,settle:a},r},flush(){k&&(N(),k.port.postMessage({event:`interrupt`}))},async close(){if(!M.signal.aborted){M.abort(),await O.stop(),D.disconnect(),O.node.disconnect(),k&&k.disconnect();for(let e of E.getTracks())e.stop();await w()}},async[Symbol.asyncDispose](){await I.close()}};return I}export{f as createVoiceIO,c as openPreConnectCapture};