@alexkroman1/aai-ui 0.12.2 → 0.13.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 (215) hide show
  1. package/README.md +257 -0
  2. package/dist/_colors-CpZO-88A.js +78 -0
  3. package/dist/_module-url-C13kAJ87.js +15 -0
  4. package/dist/_page-shell.d.ts +11 -0
  5. package/dist/_recover-run.d.ts +79 -0
  6. package/dist/_repeat-until.d.ts +30 -0
  7. package/dist/_run-controls.d.ts +33 -0
  8. package/dist/_submission-state.d.ts +92 -0
  9. package/dist/_tap-to-talk-state.d.ts +88 -0
  10. package/dist/_utils-Mi-SCFA1.js +47 -0
  11. package/dist/_utils.d.ts +26 -0
  12. package/dist/_web-storage.d.ts +43 -0
  13. package/dist/_workflow-api-ref.d.ts +37 -0
  14. package/dist/_workflow-files.d.ts +34 -0
  15. package/dist/_workflow-test-defs.d.ts +38 -0
  16. package/dist/aai-logo-D8pMVn9_.js +85 -0
  17. package/dist/agent-state-labels.d.ts +65 -0
  18. package/dist/agent-state.d.ts +108 -0
  19. package/dist/audio/capture.d.ts +44 -0
  20. package/dist/audio/index.d.ts +18 -0
  21. package/dist/audio/index.js +431 -0
  22. package/dist/audio/preconnect.d.ts +87 -0
  23. package/dist/audio/voice-io.d.ts +112 -0
  24. package/dist/build-default-client.d.ts +1 -0
  25. package/dist/chat-view-BCbI7gD4.js +396 -0
  26. package/dist/client-config-C7fQrioM.js +139 -0
  27. package/dist/client-config.d.ts +119 -0
  28. package/dist/client-dir.d.ts +44 -0
  29. package/dist/client-dir.js +58 -0
  30. package/dist/client-identity.d.ts +131 -0
  31. package/dist/client-tool.d.ts +51 -0
  32. package/dist/components/_colors.d.ts +73 -0
  33. package/dist/components/_form-readiness.d.ts +56 -0
  34. package/dist/components/_form-values.d.ts +19 -0
  35. package/dist/components/aai-logo.d.ts +12 -0
  36. package/dist/components/audio-result.d.ts +94 -0
  37. package/dist/components/auto-scroll.d.ts +74 -0
  38. package/dist/components/bullet-list.d.ts +74 -0
  39. package/dist/components/button.d.ts +58 -0
  40. package/dist/components/button.js +85 -0
  41. package/dist/components/chat-view.d.ts +35 -0
  42. package/dist/components/chat-view.js +3 -0
  43. package/dist/components/console-shell.d.ts +85 -0
  44. package/dist/components/controls.d.ts +33 -0
  45. package/dist/components/controls.js +49 -0
  46. package/dist/components/conversation-view.d.ts +130 -0
  47. package/dist/components/eyebrow.d.ts +12 -0
  48. package/dist/components/facts.d.ts +81 -0
  49. package/dist/components/form-fields.d.ts +142 -0
  50. package/dist/components/form-types.d.ts +73 -0
  51. package/dist/components/form.d.ts +96 -0
  52. package/dist/components/markdown.d.ts +57 -0
  53. package/dist/components/message-list.d.ts +48 -0
  54. package/dist/components/message-list.js +3 -0
  55. package/dist/components/session-controls.d.ts +125 -0
  56. package/dist/components/session-error-banner.d.ts +69 -0
  57. package/dist/components/session-state-dot.d.ts +111 -0
  58. package/dist/components/sidebar-layout.d.ts +42 -0
  59. package/dist/components/sidebar-layout.js +56 -0
  60. package/dist/components/start-screen.d.ts +37 -0
  61. package/dist/components/start-screen.js +71 -0
  62. package/dist/components/tool-call-block.d.ts +28 -0
  63. package/dist/components/tool-call-block.js +3 -0
  64. package/dist/components/tool-call-row.d.ts +75 -0
  65. package/dist/components/tool-config-context.d.ts +22 -0
  66. package/dist/components/upload-progress.d.ts +76 -0
  67. package/dist/components/url-chips.d.ts +29 -0
  68. package/dist/components/workflow-fields.d.ts +46 -0
  69. package/dist/components/workflow-pending-note.d.ts +81 -0
  70. package/dist/components/workflow-progress.d.ts +81 -0
  71. package/dist/components/workflow-run-error.d.ts +48 -0
  72. package/dist/components/workflow-run-panel.d.ts +79 -0
  73. package/dist/context.d.ts +288 -0
  74. package/dist/context.js +382 -0
  75. package/dist/conversation-log.d.ts +88 -0
  76. package/dist/default-client/assets/_module-url-BX0RuRU2.js +1 -0
  77. package/dist/default-client/assets/audio-cGFGID-2.js +1 -0
  78. package/dist/default-client/assets/capture-processor-BUABoo3_.js +90 -0
  79. package/dist/default-client/assets/index-8ihb1Urm.css +2 -0
  80. package/dist/default-client/assets/index-BQURVARl.js +126 -0
  81. package/dist/default-client/assets/playback-processor-ABOa11j0.js +314 -0
  82. package/dist/default-client/favicon.ico +0 -0
  83. package/dist/default-client/index.html +15 -0
  84. package/dist/default-client.d.ts +23 -0
  85. package/dist/define-client.d.ts +162 -26
  86. package/dist/define-client.js +201 -55
  87. package/dist/eyebrow-UfmSz9yy.js +27 -0
  88. package/dist/hooks-DN4gHYsj.js +182 -0
  89. package/dist/hooks.d.ts +172 -0
  90. package/dist/hooks.js +3 -0
  91. package/dist/hooks.test-d.d.ts +14 -0
  92. package/dist/inbox-protocol.d.ts +128 -0
  93. package/dist/inbox.d.ts +132 -0
  94. package/dist/index.d.ts +129 -32
  95. package/dist/index.js +5503 -18
  96. package/dist/internal.d.ts +43 -0
  97. package/dist/internal.js +8 -0
  98. package/dist/linked-client.d.ts +70 -0
  99. package/dist/message-list-DHZcLhRA.js +682 -0
  100. package/dist/notice-player.d.ts +70 -0
  101. package/dist/page.d.ts +114 -0
  102. package/dist/phone.d.ts +50 -0
  103. package/dist/route-fetch.d.ts +79 -0
  104. package/dist/session/audio-effects.d.ts +31 -0
  105. package/dist/session/audio-setup.d.ts +57 -0
  106. package/dist/session/audio-state.d.ts +157 -0
  107. package/dist/session/browser-session.d.ts +30 -0
  108. package/dist/session/close.d.ts +26 -0
  109. package/dist/session/dial.d.ts +65 -0
  110. package/dist/session/handshake.d.ts +60 -0
  111. package/dist/session/index.d.ts +17 -0
  112. package/dist/session/index.js +2 -0
  113. package/dist/session/messages.d.ts +63 -0
  114. package/dist/session/mic.d.ts +64 -0
  115. package/dist/session/preconnect.d.ts +48 -0
  116. package/dist/session/reconnect.d.ts +25 -0
  117. package/dist/session/resume-store.d.ts +57 -0
  118. package/dist/session/state.d.ts +146 -0
  119. package/dist/session/ticket.d.ts +44 -0
  120. package/dist/session/types.d.ts +476 -0
  121. package/dist/session/url.d.ts +17 -0
  122. package/dist/session/user-turn.d.ts +55 -0
  123. package/dist/session-CE0Ipsb3.js +2129 -0
  124. package/dist/stored-value.d.ts +103 -0
  125. package/dist/tool-call-block-uXtMOUWN.js +204 -0
  126. package/dist/tool-config-context-Es4YUzV2.js +19 -0
  127. package/dist/types.d.ts +272 -34
  128. package/dist/types.js +35 -4
  129. package/dist/upload/files.d.ts +83 -0
  130. package/dist/upload/index.d.ts +15 -0
  131. package/dist/upload/pause.d.ts +31 -0
  132. package/dist/upload/recall.d.ts +67 -0
  133. package/dist/upload/report.d.ts +26 -0
  134. package/dist/upload/session.d.ts +99 -0
  135. package/dist/url-chips-oyy_Eiqh.js +239 -0
  136. package/dist/use-client-runs.d.ts +87 -0
  137. package/dist/use-conversation-log.d.ts +86 -0
  138. package/dist/use-conversation.d.ts +131 -0
  139. package/dist/use-copy.d.ts +74 -0
  140. package/dist/use-download-url.d.ts +84 -0
  141. package/dist/use-flash.d.ts +61 -0
  142. package/dist/use-inbox.d.ts +101 -0
  143. package/dist/use-push-to-talk.d.ts +105 -0
  144. package/dist/use-route-mutation.d.ts +113 -0
  145. package/dist/use-route.d.ts +82 -0
  146. package/dist/use-run-key.d.ts +133 -0
  147. package/dist/use-session-controls.d.ts +69 -0
  148. package/dist/use-session-id.d.ts +57 -0
  149. package/dist/use-tap-to-talk.d.ts +138 -0
  150. package/dist/use-user-transcript-uyHhzy4d.js +73 -0
  151. package/dist/use-user-transcript.d.ts +75 -0
  152. package/dist/use-workflow-form.d.ts +285 -0
  153. package/dist/use-workflow-progress.d.ts +137 -0
  154. package/dist/use-workflow-run-DdhWmbx-.js +697 -0
  155. package/dist/use-workflow-run.d.ts +100 -0
  156. package/dist/use-workflow-runs.d.ts +71 -0
  157. package/dist/use-workflow-stream.d.ts +154 -0
  158. package/dist/use-workflows.d.ts +77 -0
  159. package/dist/workflow-client.d.ts +136 -0
  160. package/dist/workflow-def-types.d.ts +31 -0
  161. package/dist/workflow-events.d.ts +39 -0
  162. package/dist/workflow-field-kind.d.ts +95 -0
  163. package/dist/workflow-status-labels.d.ts +35 -0
  164. package/dist/worklets/_module-url.d.ts +10 -0
  165. package/dist/worklets/_playback-bench-harness.d.ts +181 -0
  166. package/dist/worklets/_playback-bench-host.d.ts +63 -0
  167. package/dist/worklets/_playback-bench-page.d.ts +65 -0
  168. package/dist/worklets/_tts-trace-harness.d.ts +142 -0
  169. package/dist/worklets/_worklet-test-utils.d.ts +27 -0
  170. package/dist/worklets/capture-processor.d.ts +4 -2
  171. package/dist/worklets/capture-processor.js +73 -33
  172. package/dist/worklets/playback-processor.d.ts +4 -2
  173. package/dist/worklets/playback-processor.js +258 -42
  174. package/package.json +58 -31
  175. package/styles.css +152 -30
  176. package/dist/_components/app.d.ts +0 -23
  177. package/dist/_components/app.js +0 -41
  178. package/dist/_components/button.d.ts +0 -50
  179. package/dist/_components/button.js +0 -46
  180. package/dist/_components/chat-view.d.ts +0 -29
  181. package/dist/_components/chat-view.js +0 -61
  182. package/dist/_components/controls.d.ts +0 -18
  183. package/dist/_components/controls.js +0 -37
  184. package/dist/_components/error-banner.d.ts +0 -21
  185. package/dist/_components/error-banner.js +0 -27
  186. package/dist/_components/message-bubble.d.ts +0 -23
  187. package/dist/_components/message-bubble.js +0 -35
  188. package/dist/_components/message-list.d.ts +0 -21
  189. package/dist/_components/message-list.js +0 -76
  190. package/dist/_components/sidebar-layout.d.ts +0 -21
  191. package/dist/_components/sidebar-layout.js +0 -36
  192. package/dist/_components/start-screen.d.ts +0 -26
  193. package/dist/_components/start-screen.js +0 -51
  194. package/dist/_components/state-indicator.d.ts +0 -29
  195. package/dist/_components/state-indicator.js +0 -37
  196. package/dist/_components/thinking-indicator.d.ts +0 -16
  197. package/dist/_components/thinking-indicator.js +0 -34
  198. package/dist/_components/tool-call-block.d.ts +0 -27
  199. package/dist/_components/tool-call-block.js +0 -118
  200. package/dist/_components/tool-icons.d.ts +0 -17
  201. package/dist/_components/tool-icons.js +0 -128
  202. package/dist/_components/transcript.d.ts +0 -25
  203. package/dist/_components/transcript.js +0 -35
  204. package/dist/audio.d.ts +0 -45
  205. package/dist/audio.js +0 -121
  206. package/dist/client-context.d.ts +0 -33
  207. package/dist/client-context.js +0 -15
  208. package/dist/client-handler.d.ts +0 -43
  209. package/dist/session-CWB7vmqz.js +0 -429
  210. package/dist/session.d.ts +0 -69
  211. package/dist/session.js +0 -2
  212. package/dist/signals.d.ts +0 -120
  213. package/dist/signals.js +0 -161
  214. package/dist/tsdown.config.d.ts +0 -2
  215. package/dist/types.test-d.d.ts +0 -7
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Which control one property of a workflow's input schema renders as.
3
+ *
4
+ * `<WorkflowFields>` walks a workflow's `inputSchema` and picks a control per
5
+ * property. This is that decision with nothing rendered — ONE function, called
6
+ * by `SchemaField` itself, so a reader outside the component can ask the same
7
+ * question and get the same answer.
8
+ *
9
+ * **Why it is published.** The rule is not derivable from the schema by
10
+ * inspection: an upload property IS a plain `string` there, and the ORDER
11
+ * matters in a way no reader can see. The studio's API pane documents the
12
+ * form-to-JSON correspondence for every workflow app, and it was carrying a
13
+ * hand-kept mirror of the switch below — "a declared upload beats everything,
14
+ * then `enum`, then …" — with a comment admitting as much and a test pinning
15
+ * the order to catch the drift. A copy that drifted would name a control the
16
+ * reader cannot find on their own page. Nothing else could be done about it
17
+ * while the decision was private, and the same reasoning already settled the
18
+ * studio's SDK-subpath list: mirroring "would just move the drift somewhere
19
+ * new".
20
+ *
21
+ * So a NEW control added to `<WorkflowFields>` is a new member of
22
+ * {@link WorkflowFieldKind}, and every reader of the rule finds out from the
23
+ * type rather than from a table somebody remembered to edit.
24
+ */
25
+ /**
26
+ * The control a schema property renders as inside `<WorkflowFields>`.
27
+ *
28
+ * `"none"` is not a control: it is the shape the generated form declines to
29
+ * guess at — a nested object, an array — because every choice (a JSON
30
+ * textarea, a repeater, a comma-separated string) is a guess about what the
31
+ * author meant, and a guess producing a value the schema then rejects is worse
32
+ * than no field. The API takes those shapes perfectly well; only the generated
33
+ * form has nothing honest to draw, so the field is written by hand and
34
+ * composes with the generated ones inside the same `<Form>`.
35
+ *
36
+ * There is deliberately no `"textarea"` member. A textarea is a string like a
37
+ * text field is — the same value over the wire — so it is a hand-written swap
38
+ * rather than a shape the schema can ask for.
39
+ *
40
+ * @public
41
+ */
42
+ export type WorkflowFieldKind = "text" | "number" | "select" | "checkbox" | "file" | "none";
43
+ /**
44
+ * A property's declared type, taking the first non-null member of a union.
45
+ *
46
+ * `["string", "null"]` is how an optional-and-nullable field converts; the
47
+ * control is the one the non-null half wants. Not exported from the package
48
+ * barrel — the published question is the KIND, and this is the one detail
49
+ * `<WorkflowFields>` needs past it (an integer's `step`).
50
+ */
51
+ export declare function schemaTypeOf(schema: unknown): string | undefined;
52
+ /**
53
+ * Which control `<WorkflowFields>` renders for one property of an input schema.
54
+ *
55
+ * @remarks
56
+ * The ORDER is the contract, and it is the half a reader cannot infer:
57
+ *
58
+ * 1. **A declared upload wins outright.** It is a plain `string` in the schema
59
+ * — the id — so testing the type first would render a text box asking a
60
+ * person to type an id no person has. The declaration is the workflow's
61
+ * (`workflow({ uploads: [...] })`), not the schema's, precisely because a
62
+ * marker inside the schema would only work for the library that carried it.
63
+ * 2. **Then a non-empty `enum`**, before the type switch: an enum of strings
64
+ * is a `string` too, and a select is the narrower, better control.
65
+ * 3. **Then the type** — `boolean`, `number`/`integer`, `string`.
66
+ * 4. **Anything else is `"none"`** rather than a guess. See
67
+ * {@link WorkflowFieldKind}.
68
+ *
69
+ * @example
70
+ * ```ts
71
+ * import { fieldKindFor, type WorkflowFieldKind } from "@alexkroman1/aai-ui";
72
+ * import type { WorkflowSummary } from "@alexkroman1/aai/workflow-api";
73
+ *
74
+ * function kindsOf(summary: WorkflowSummary): Record<string, WorkflowFieldKind> {
75
+ * const uploads = new Set(summary.uploads ?? []);
76
+ * const schema = summary.inputSchema as { properties?: Record<string, unknown> };
77
+ * return Object.fromEntries(
78
+ * Object.entries(schema.properties ?? {}).map(([name, property]) => [
79
+ * name,
80
+ * fieldKindFor(property, { upload: uploads.has(name) }),
81
+ * ]),
82
+ * );
83
+ * }
84
+ * ```
85
+ *
86
+ * @param schema - The property's JSON Schema, as `GET workflows` reports it.
87
+ * Anything that is not an object reads as `"none"`.
88
+ * @param options - `upload` says the property is named in the workflow's own
89
+ * `uploads` declaration, which is what step 1 above tests.
90
+ *
91
+ * @public
92
+ */
93
+ export declare function fieldKindFor(schema: unknown, options?: {
94
+ upload?: boolean | undefined;
95
+ }): WorkflowFieldKind;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * One line describing where a run has got to, for each status there is.
3
+ *
4
+ * Two template pages carried a byte-identical `Record<WorkflowRun["status"],
5
+ * string>` with the same doc comment, differing only in the `running` label —
6
+ * which is the one a page really does want to write for itself ("Writing…",
7
+ * "Transcribing…") and the only one. The other four are the same sentence about
8
+ * the same five-member union everywhere.
9
+ *
10
+ * The exhaustiveness argument both copies were written for survives, and moves
11
+ * to the SDK boundary: this is a `Record` over {@link WorkflowRunStatus} rather
12
+ * than a `switch`, so a status added to the SDK is a compile error HERE — in one
13
+ * place that every page then inherits — instead of falling through a `default:`
14
+ * in each of them into whichever line was last. A page overriding one member
15
+ * keeps that: spreading a complete record cannot drop a key.
16
+ */
17
+ import type { WorkflowRunStatus } from "@alexkroman1/aai/workflow-api";
18
+ /**
19
+ * The default status line per {@link WorkflowRunStatus}.
20
+ *
21
+ * Override the ones your page has a better word for and keep the rest:
22
+ *
23
+ * ```ts
24
+ * import { WORKFLOW_STATUS_LABELS } from "@alexkroman1/aai-ui";
25
+ *
26
+ * const STATUS_LINE = { ...WORKFLOW_STATUS_LABELS, running: "Writing…" };
27
+ * ```
28
+ *
29
+ * The wording is deliberately about the RUN rather than about the work — a page
30
+ * knows what its workflow does and this does not, so `running` is the neutral
31
+ * "Working…" and every page that cares replaces exactly that key.
32
+ *
33
+ * @public
34
+ */
35
+ export declare const WORKFLOW_STATUS_LABELS: Readonly<Record<WorkflowRunStatus, string>>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Blob-URL module for an inlined AudioWorklet processor source.
3
+ *
4
+ * A blob URL rather than a data URI because the agent page's CSP allows
5
+ * `script-src blob:` but not `data:` — a data-URI module fails `addModule`
6
+ * with the opaque "Unable to load a worklet's module". Every inline worklet
7
+ * in this package must load through this helper so that constraint lives in
8
+ * one place.
9
+ */
10
+ export declare function workletModuleUrl(source: string): string;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * A bench for the playback jitter buffer: real TTS, the real worklet, a
3
+ * deterministic clock, and one setting changed at a time.
4
+ *
5
+ * Why this exists. `PLAYBACK_JITTER_MS` and `PLAYBACK_REFILL_MS` are the two
6
+ * numbers that decide whether a reply is heard as speech or as stutter, and
7
+ * every test that touches them supplies its own arrival pattern:
8
+ * `playback-processor.test.ts` hand-feeds quanta, and `audio-stress.test.ts`
9
+ * records in its own header that its chunk sizes outrun the render loop by an
10
+ * order of magnitude so the buffer "effectively never starves". Neither can
11
+ * answer "is 400 the right number", because neither has ever seen how audio
12
+ * actually arrives.
13
+ *
14
+ * So the bench replays a RECORDED reply (`_tts-trace-harness.ts`) through the
15
+ * chain the audio really crosses:
16
+ *
17
+ * provider frames (recorded arrival times)
18
+ * -> the server's bounded-lead pacer <- MODELLED, see below
19
+ * -> a network profile (latency, jitter, stalls)
20
+ * -> the real playback worklet <- `playbackProcessorSource`
21
+ * -> rendered PCM + the worklet's own stats
22
+ *
23
+ * Everything is virtual-time: the clock advances one 128-sample render quantum
24
+ * per iteration, so a sweep of a hundred settings over a 30-second reply costs
25
+ * seconds and gives byte-identical results every run. {@link renderToWav} is
26
+ * the other half — the rendered output written where a human can listen to it,
27
+ * because a concealment counter does not tell you whether a reply sounds
28
+ * broken.
29
+ *
30
+ * **The pacer is a MODEL, and that is this bench's one fidelity gap.**
31
+ * `createAudioPacer` (`aai/host/audio-pacer.ts`) is not on any published
32
+ * subpath and this package may not import a sibling's internals, so
33
+ * {@link pacedSends} is a transcription of its algorithm rather than the
34
+ * algorithm. It is ~20 lines and reproduces the two properties the pacer's own
35
+ * doc states — free flow until the lead ceiling, then one release per
36
+ * `burstMs` — but a change to the real pacer will NOT fail this file. If the
37
+ * pacer moves, re-read it against `pacedSends`. Exporting the real one and
38
+ * deleting the model is the fix; it needs a non-test change.
39
+ */
40
+ import { type TtsTrace } from "./_tts-trace-harness.ts";
41
+ /** The render quantum every AudioWorkletProcessor is called with. */
42
+ export declare const QUANTUM = 128;
43
+ /** Sampling interval of {@link RenderResult.earMs}. */
44
+ export declare const EAR_SAMPLE_MS = 20;
45
+ /** One frame arriving at the client: when it lands, and what it carries. */
46
+ export type Delivery = {
47
+ atMs: number;
48
+ bytes: Uint8Array;
49
+ };
50
+ /**
51
+ * How the server releases audio. Production values are
52
+ * `CLIENT_AUDIO_LEAD_MS` (1000) and `PACER_BURST_MS` (200); they are
53
+ * parameters here because they are half of what the bench is sweeping — the
54
+ * client's cushion is the server's lead, so tuning the jitter buffer without
55
+ * them is tuning one end of one number.
56
+ */
57
+ export type PacerProfile = {
58
+ leadMs: number;
59
+ burstMs: number;
60
+ };
61
+ /** What the link does to a frame between the server and the ear. */
62
+ export type NetworkProfile = {
63
+ name: string;
64
+ /** One-way latency floor, ms. */
65
+ latencyMs: number;
66
+ /** Peak extra delay added on top, ms (0 = a perfectly even link). */
67
+ jitterMs: number;
68
+ /**
69
+ * Freezes: `{ atMs, forMs }` pairs during which nothing is delivered, every
70
+ * held frame landing at the end. This is what a jitter buffer is FOR, and
71
+ * the only part of a profile that has to be scripted rather than sampled —
72
+ * a stall is an event, not a distribution.
73
+ */
74
+ stalls?: {
75
+ atMs: number;
76
+ forMs: number;
77
+ }[];
78
+ /**
79
+ * Throughput ceiling in bits/s, or `Infinity`. Both legs are uncompressed
80
+ * PCM16 (384 kbps down at 24 kHz), so a link that cannot carry the bitrate
81
+ * cannot be fixed by any buffer — that claim is in `src/worklets/CLAUDE.md` and this
82
+ * is what makes it measurable.
83
+ */
84
+ bitsPerSecond?: number;
85
+ };
86
+ /**
87
+ * The worklet's one knob. It was two — a startup target and a refill target —
88
+ * until this bench showed the startup one was redundant by construction; see
89
+ * `PLAYBACK_FILL_MS`.
90
+ */
91
+ export type PlaybackSettings = {
92
+ fillMs: number;
93
+ };
94
+ /**
95
+ * Model the server's bounded-lead pacer over a trace's frames.
96
+ *
97
+ * A transcription of `createAudioPacer`, whose loop is: a frame is sent
98
+ * immediately while the lead (everything sent so far, minus now) is under the
99
+ * ceiling; otherwise it waits until the lead has drained `burstMs` below the
100
+ * ceiling and then goes out with everything else the drain releases.
101
+ */
102
+ export declare function pacedSends(trace: TtsTrace, pacer: PacerProfile): Delivery[];
103
+ /** Apply a network profile to the server's send schedule. */
104
+ export declare function overNetwork(sends: Delivery[], net: NetworkProfile): Delivery[];
105
+ /** Everything one render of one setting produced. */
106
+ export type RenderResult = {
107
+ /** The audio the ear actually received, at the context sample rate. */
108
+ rendered: Float32Array;
109
+ sampleRate: number;
110
+ /** Ms from the first frame LEAVING the provider to the first audible sample. */
111
+ timeToFirstAudioMs: number;
112
+ /** The worklet's own report, as `onPlaybackStats` would receive it. */
113
+ stats: {
114
+ concealedSamples: number;
115
+ silentConcealedSamples: number;
116
+ concealmentEvents: number;
117
+ silentConcealmentEvents: number;
118
+ };
119
+ /** Every concealment episode's length in ms, in order. */
120
+ gapsMs: number[];
121
+ /** `bufferedMs` values the worklet reported, as `playback_progress` would. */
122
+ progressMs: number[];
123
+ /**
124
+ * Ground truth for the heard cursor: cumulative ms of the REPLY's own audio
125
+ * the ear had received, sampled every {@link EAR_SAMPLE_MS}.
126
+ *
127
+ * Concealed samples are excluded — they are fabricated, not reply audio — so
128
+ * this is exactly the quantity `heardMs()` in
129
+ * `aai/host/transports/pipeline-heard.ts` estimates, which is what makes the
130
+ * two comparable.
131
+ */
132
+ earMs: number[];
133
+ /** How long the reply took to play out, first sample to last. */
134
+ playedMs: number;
135
+ };
136
+ /**
137
+ * Render one delivery schedule through the real playback worklet.
138
+ *
139
+ * The clock is the render loop itself: quantum `q` happens at
140
+ * `q * QUANTUM / sampleRate` seconds, every delivery at or before that instant
141
+ * is written first, and `done` is posted once the last frame has landed. That
142
+ * is the ordering the audio thread really sees (`onmessage` and `process()`
143
+ * never interleave), and it makes the whole render a pure function of the
144
+ * schedule.
145
+ */
146
+ export declare function renderSchedule(deliveries: Delivery[], opts: {
147
+ sampleRate: number;
148
+ settings: PlaybackSettings;
149
+ maxSeconds?: number;
150
+ }): RenderResult;
151
+ /** One end-to-end run: trace + pacer + network + settings. */
152
+ export declare function runBench(opts: {
153
+ trace: TtsTrace;
154
+ pacer: PacerProfile;
155
+ net: NetworkProfile;
156
+ settings: PlaybackSettings;
157
+ }): RenderResult;
158
+ /**
159
+ * Score one render. Lower is better, and the WEIGHTS are the opinion in this
160
+ * file — everything above is measurement.
161
+ *
162
+ * The three terms are not interchangeable:
163
+ *
164
+ * - **Startup latency** is paid on every single turn, so it is the term that
165
+ * compounds over a conversation.
166
+ * - **Silent concealment** is an audible hole. Concealment that stays under
167
+ * the fade is a smear the ear largely forgives, which is why the worklet
168
+ * reports the two separately, so silence is weighted an order of magnitude
169
+ * higher than concealment in general.
170
+ * - **Episode COUNT** matters independently of total length: one 300 ms pause
171
+ * reads as a network hiccup, where six 50 ms ones read as a broken codec.
172
+ * That is the whole finding behind the refill re-arm, so a score that
173
+ * summed milliseconds alone would rank the failure it was built to prevent
174
+ * as equal to the fix.
175
+ */
176
+ export declare function scoreRender(r: RenderResult): {
177
+ score: number;
178
+ parts: Record<string, number>;
179
+ };
180
+ /** Minimal 16-bit PCM WAV, so a render can be listened to. */
181
+ export declare function toWav(samples: Float32Array, sampleRate: number): Buffer;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The bench's HOST-side half: what the server believes about the caller's
3
+ * playback, against what the caller actually heard.
4
+ *
5
+ * Split from `_playback-bench-harness.ts` at the seam it already had — that file
6
+ * models the wire and drives the worklet, and this one models
7
+ * `aai/host/transports/pipeline-heard.ts`'s arithmetic over the result. It went
8
+ * out when the file hit the 500-line cap, which is the right time rather than a
9
+ * line later.
10
+ *
11
+ * Everything here is a TRANSCRIPTION of host code this package may not import
12
+ * (`aai`'s `host/` is Node-only and internal), so it carries the same warning the
13
+ * pacer model does: a change to `createPlaybackClock` will not fail this file.
14
+ * Re-read it against these functions if that clock moves.
15
+ */
16
+ import type { Delivery, RenderResult } from "./_playback-bench-harness.ts";
17
+ /**
18
+ * What the HOST believes about playback, and what actually happened.
19
+ *
20
+ * The barge-in floor is `pending()` in `aai/host/transports/pipeline-heard.ts`:
21
+ * `now() < endsAtMs + PIPELINE_PLAYBACK_GRACE_MS`, where `endsAtMs` accumulates
22
+ * each forwarded chunk's duration from `max(endsAtMs, now())` — an OPEN-LOOP
23
+ * model that assumes playback starts the instant a chunk is forwarded and runs at
24
+ * exactly 1.0x. No real client beats that, so the model is a lower bound and the
25
+ * grace is what covers the difference.
26
+ *
27
+ * This measures the difference. `requiredGraceMs` is how long after the host's
28
+ * estimate the caller was still hearing audio — i.e. the smallest grace that
29
+ * keeps barge-in working to the end of a reply. It is a REQUIREMENT, so a grace
30
+ * at or above it is correct and one below it means a barge-in in the reply's tail
31
+ * is not recognised as arriving during playback.
32
+ *
33
+ * Two clients are modelled because the host serves both: one that wires
34
+ * `onPlaybackProgress` (the browser does) and one that does not (a telephony
35
+ * bridge, a harness). The reports clamp `endsAtMs` UPWARD only, so a reporting
36
+ * client shrinks the requirement and a silent one leaves the host on the
37
+ * open-loop estimate — which is the case the constant has to be safe for.
38
+ */
39
+ export type PlayoutVsHost = {
40
+ /** When the host's open-loop model thinks forwarded audio finishes playing. */
41
+ openLoopEndMs: number;
42
+ /** The same, with the client's `playback_progress` reports clamped in. */
43
+ reportedEndMs: number;
44
+ /** When the caller actually stopped hearing audio. */
45
+ realEndMs: number;
46
+ /** Smallest grace that keeps `pending()` true to `realEndMs`, unreported. */
47
+ requiredGraceMs: number;
48
+ /** The same for a client that DOES report its backlog. */
49
+ requiredGraceReportingMs: number;
50
+ };
51
+ /**
52
+ * Replay a render against the host's own playback-clock arithmetic.
53
+ *
54
+ * `deliveries` is what the host FORWARDED (the pacer's output, before the link),
55
+ * because that is what `onChunk` sees — the host has no view of the wire.
56
+ */
57
+ export declare function playoutVsHost(opts: {
58
+ forwarded: Delivery[];
59
+ render: RenderResult;
60
+ sampleRate: number;
61
+ /** Interval of the client's backlog reports; production is 500 ms. */
62
+ reportIntervalMs: number;
63
+ }): PlayoutVsHost;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The browser half of the playback bench: a page that plays a recorded reply
3
+ * through the REAL playback worklet in a REAL `AudioContext`, so the tuning can
4
+ * be listened to rather than only counted.
5
+ *
6
+ * This is the part the offline renderer cannot be: `renderSchedule` drives
7
+ * `process()` on a synthetic sample clock with `postMessage` delivered exactly
8
+ * between quanta, which is the ordering the audio thread guarantees but says
9
+ * nothing about the browser's own render cadence, its output latency, or what
10
+ * a `port.postMessage` burst does to a live audio callback. If the two agree,
11
+ * the offline sweep is trustworthy; where they disagree, the browser is right.
12
+ *
13
+ * Two ways in, same page:
14
+ *
15
+ * - **A human opens it** and gets a slider for `fillMs`, the
16
+ * network profile, and the pacer lead, with the worklet's own concealment
17
+ * counters live. Audio comes out of the speakers.
18
+ * - **Playwright drives it** through {@link BENCH_API} on `window`, and pulls
19
+ * back the samples the tap captured — the audio that actually reached the
20
+ * destination — so a test can diff it against the offline render.
21
+ *
22
+ * The page is a STRING rather than a file under a bundler because it must stay
23
+ * test-only: aai-ui's build (`tsdown` + `build-default-client.ts`) ships
24
+ * `dist/default-client/`, and a second HTML entry there is a product artifact
25
+ * with a coverage floor and a size budget. Generated into `reports/playback/`,
26
+ * it is neither.
27
+ */
28
+ /** The name of the object the page exposes for a driver to call. */
29
+ export declare const BENCH_API = "__aaiPlaybackBench";
30
+ /** Options a driver or a human can set on one run. */
31
+ export type BenchRunOptions = {
32
+ fillMs: number;
33
+ /** Deliveries: ms since run start, and the slice of the PCM to write. */
34
+ schedule: {
35
+ atMs: number;
36
+ offset: number;
37
+ length: number;
38
+ }[];
39
+ /** Whether the reply is audible. A sweep run in a headless browser is not. */
40
+ muted?: boolean;
41
+ };
42
+ /**
43
+ * Build the bench page.
44
+ *
45
+ * `pcmUrl` is fetched once and sliced per delivery, so the page replays the
46
+ * same bytes the offline renderer does. `sampleRate` must be the trace's: the
47
+ * context is created at it and the page REFUSES to run if the browser grants
48
+ * another, exactly as `audio/voice-io.ts` does — PCM written into a context at the wrong
49
+ * rate plays at the wrong speed, which would look like a tuning result.
50
+ */
51
+ export declare function benchPageHtml(opts: {
52
+ pcmUrl: string;
53
+ sampleRate: number;
54
+ /** Shown in the header, so a saved page names what it is playing. */
55
+ title: string;
56
+ /** Default schedules a human can pick between, by profile name. */
57
+ profiles: Record<string, {
58
+ atMs: number;
59
+ offset: number;
60
+ length: number;
61
+ }[]>;
62
+ defaults?: {
63
+ fillMs: number;
64
+ };
65
+ }): string;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Capture and replay of REAL TTS arrival timing.
3
+ *
4
+ * The playback worklet's tuning question — how deep should
5
+ * `PLAYBACK_JITTER_MS` be — is entirely a question about how unevenly audio
6
+ * arrives, and every existing test answers it with a generated arrival pattern.
7
+ * `audio-stress.test.ts` says so itself: its chunk-size arbitrary averages
8
+ * ~750 samples against 128 consumed per render, so writes outrun renders by an
9
+ * order of magnitude and the buffer effectively never starves. A jitter buffer
10
+ * tuned against that is tuned against nothing.
11
+ *
12
+ * So a trace is a recording of one real reply as the provider actually emitted
13
+ * it: PCM16 bytes plus the millisecond each frame ARRIVED, relative to the
14
+ * first. Replayed through the real pacer and the real worklet, that makes the
15
+ * tuning question measurable and repeatable — the same reply, the same
16
+ * arrival pattern, one setting changed.
17
+ *
18
+ * Two halves, deliberately separate:
19
+ *
20
+ * - {@link captureTtsTrace} needs a live provider and an API key. It runs once,
21
+ * by hand (`AAI_CAPTURE_TTS_TRACE=1`), and commits its result.
22
+ * - {@link readTtsTrace} needs neither, so every test that CONSUMES a trace is
23
+ * keyless and offline.
24
+ *
25
+ * The bytes live beside the JSON rather than inside it: base64 in a fixture
26
+ * inflates ~1 MB of PCM by a third and makes the file unreadable in a diff,
27
+ * where a `.pcm` sidecar is `ffplay`-able and reviewed by its length alone.
28
+ */
29
+ /** One provider audio frame, with the moment it arrived. */
30
+ export type TraceFrame = {
31
+ /** Arrival time in ms, relative to the first frame of the reply. */
32
+ tMs: number;
33
+ /** Byte offset of this frame's PCM16 in the sidecar. */
34
+ offset: number;
35
+ /** Byte length of this frame's PCM16. */
36
+ length: number;
37
+ };
38
+ /** A recorded reply: what the provider sent, and when. */
39
+ export type TtsTrace = {
40
+ /** Sample rate of the PCM16 in the sidecar. */
41
+ sampleRate: number;
42
+ /** Provider kind and voice, so a trace names the thing it recorded. */
43
+ provider: string;
44
+ voice: string;
45
+ /** The reply text that was synthesized. */
46
+ text: string;
47
+ /** Ms from the first `sendText` to the first audio frame. */
48
+ firstAudioMs: number;
49
+ /** Ms from the first `sendText` to `done`. */
50
+ doneMs: number;
51
+ frames: TraceFrame[];
52
+ /** All frames' PCM16, concatenated in arrival order. */
53
+ pcm: Int16Array;
54
+ };
55
+ /**
56
+ * Total audio duration in ms. Not the same as {@link TtsTrace.doneMs}: a
57
+ * provider that synthesizes faster than real time produces more audio than the
58
+ * wall clock it took to produce it, and the RATIO of the two is the whole
59
+ * reason a jitter buffer can ever fill.
60
+ */
61
+ export declare function traceAudioMs(trace: TtsTrace): number;
62
+ /**
63
+ * The slice of the SDK's `TtsSession` a capture drives. Declared structurally
64
+ * so the harness needs no host-only type import: a real session satisfies it.
65
+ */
66
+ export type CapturableTtsSession = {
67
+ sendText(text: string): void;
68
+ flush(): void;
69
+ on(event: "audio", fn: (pcm: Int16Array) => void): unknown;
70
+ on(event: "done", fn: () => void): unknown;
71
+ on(event: "error", fn: (err: {
72
+ message?: string;
73
+ }) => void): unknown;
74
+ close(): Promise<void>;
75
+ };
76
+ /**
77
+ * Open a real TTS session, synthesize `text`, and record every frame's arrival.
78
+ *
79
+ * The text is sent the way the PIPELINE sends it — as deltas, with a final
80
+ * `flush()` — because the AssemblyAI adapter segments on what it receives and
81
+ * `Generate` only buffers until a `Flush`. Handing it the whole reply in one
82
+ * call would record a different arrival pattern from the one a real turn
83
+ * produces (see `providers/tts/assemblyai-segment.ts`).
84
+ */
85
+ export declare function captureTtsTrace(opts: {
86
+ text: string;
87
+ /**
88
+ * Opens the real provider session. INJECTED rather than resolved here: the
89
+ * resolver (`aai`'s `host/providers/resolve.ts`) is not on any published
90
+ * subpath, and this package may not import a sibling's internals. The caller
91
+ * that has one is the capture runner, which is not part of the package.
92
+ */
93
+ open: (o: {
94
+ sampleRate: number;
95
+ signal: AbortSignal;
96
+ }) => Promise<CapturableTtsSession>;
97
+ /** LLM-shaped deltas. Defaults to splitting `text` on word boundaries. */
98
+ deltas?: string[];
99
+ /** Recorded into the trace so a fixture names the voice it captured. */
100
+ voice?: string;
101
+ provider?: string;
102
+ sampleRate?: number;
103
+ /**
104
+ * Gap between deltas, in ms. **Set this to represent a real turn.**
105
+ *
106
+ * Sending every delta in one burst is what a capture does by default, and for
107
+ * the PLAYBACK question that is harmless — the pacer reshapes arrival anyway.
108
+ * For anything about SEGMENTATION it invalidates the measurement outright: the
109
+ * segmenter is handed the whole reply before it makes its first cut, so
110
+ * time-to-first-audio collapses to the service's own latency (~40 ms measured)
111
+ * and every segmentation rule scores the same. An LLM streams at ~30 ms a
112
+ * delta, which is what the segmenter really sees.
113
+ */
114
+ deltaIntervalMs?: number;
115
+ /** Hard cap, so a provider that never sends `done` cannot hang the capture. */
116
+ timeoutMs?: number;
117
+ }): Promise<TtsTrace>;
118
+ /**
119
+ * Split a reply into LLM-shaped deltas: a few words at a time, which is what
120
+ * `streamText` emits and therefore what the adapter's segmenter sees.
121
+ */
122
+ export declare function splitIntoDeltas(text: string, wordsPer?: number): string[];
123
+ /** Where a trace's two files live, given its directory and name. */
124
+ export declare function tracePaths(dir: string, name: string): {
125
+ index: string;
126
+ pcm: string;
127
+ };
128
+ export declare function writeTtsTrace(dir: string, name: string, trace: TtsTrace): Promise<void>;
129
+ /** Whether both halves of a trace are present, for a `skipIf` that announces. */
130
+ export declare function hasTtsTrace(dir: string, name: string): boolean;
131
+ export declare function readTtsTrace(dir: string, name: string): Promise<TtsTrace>;
132
+ /**
133
+ * The same read, synchronously.
134
+ *
135
+ * A `describe` body may not `await` — vitest collects it synchronously — so a
136
+ * suite whose every case shares one trace has no other way to load it once.
137
+ * Reading it per `test` instead would decode 375 KiB of PCM ten times over for
138
+ * a value that is immutable.
139
+ */
140
+ export declare function readTtsTraceSync(dir: string, name: string): TtsTrace;
141
+ /** The PCM16 bytes of one frame, as the wire would carry them. */
142
+ export declare function frameBytes(trace: TtsTrace, frame: TraceFrame): Uint8Array;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Test harness for AudioWorklet processor sources.
3
+ *
4
+ * The worklets ship as source strings (compiled to Blob URLs for the real
5
+ * AudioWorklet). This harness evaluates a source string with stubbed
6
+ * AudioWorkletGlobalScope globals so the processor's runtime behavior
7
+ * (batching, resampling, ring buffer) can be exercised directly in unit tests.
8
+ */
9
+ export type WorkletPort = {
10
+ onmessage: ((e: {
11
+ data: unknown;
12
+ }) => void) | null;
13
+ postMessage(data: unknown, transfer?: unknown[]): void;
14
+ };
15
+ export type WorkletInstance = {
16
+ port: WorkletPort;
17
+ process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean;
18
+ };
19
+ export type WorkletHarness = {
20
+ instance: WorkletInstance;
21
+ /** Messages the processor posted to the main thread, in order. */
22
+ posted: unknown[];
23
+ /** Deliver a message from the main thread to the processor. */
24
+ sendMessage(data: unknown): void;
25
+ };
26
+ /** Evaluate a worklet source string and instantiate its registered processor. */
27
+ export declare function instantiateWorklet(source: string, processorOptions?: Record<string, unknown>, contextSampleRate?: number): WorkletHarness;
@@ -1,2 +1,4 @@
1
- declare const src: string;
2
- export default src;
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;