@alexkroman1/aai-ui 5.13.2 → 6.1.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 (57) hide show
  1. package/README.md +2 -1
  2. package/dist/_repeat-until.d.ts +30 -0
  3. package/dist/_sse.d.ts +56 -0
  4. package/dist/_workflow-api-ref.d.ts +37 -0
  5. package/dist/audio.js +26 -26
  6. package/dist/{chat-view-DadZOvJO.js → chat-view-CK61bWWx.js} +4 -3
  7. package/dist/components/_form-values.d.ts +19 -0
  8. package/dist/components/auto-scroll.d.ts +63 -0
  9. package/dist/components/chat-view.js +1 -1
  10. package/dist/components/controls.js +1 -1
  11. package/dist/components/form-types.d.ts +67 -0
  12. package/dist/components/form.d.ts +138 -0
  13. package/dist/components/message-list.js +1 -1
  14. package/dist/components/start-screen.js +1 -1
  15. package/dist/components/tool-call-block.js +1 -1
  16. package/dist/components/workflow-fields.d.ts +57 -0
  17. package/dist/components/workflow-progress.d.ts +55 -0
  18. package/dist/default-client/assets/audio-fO7SVU64.js +1 -0
  19. package/dist/default-client/assets/{capture-processor-CWRLCPS3.js → capture-processor-Dmc-KEpb.js} +4 -4
  20. package/dist/default-client/assets/client-audio-constants-Ck0IJO4c.js +1 -0
  21. package/dist/default-client/assets/index-4u908fff.js +293 -0
  22. package/dist/default-client/assets/index-CDugAuLK.css +2 -0
  23. package/dist/default-client/assets/{playback-processor-SKKE9qu2.js → playback-processor-DUsmALNH.js} +10 -3
  24. package/dist/default-client/index.html +3 -2
  25. package/dist/define-client.d.ts +40 -1
  26. package/dist/define-client.js +61 -19
  27. package/dist/hooks.d.ts +30 -0
  28. package/dist/hooks.js +4 -26
  29. package/dist/index.d.ts +11 -0
  30. package/dist/index.js +1597 -7
  31. package/dist/{message-list-YdLocGoT.js → message-list-BwA3rdPi.js} +105 -27
  32. package/dist/page.d.ts +88 -0
  33. package/dist/{session-core-BA8H3qtF.js → session-core-ClKdVgRU.js} +245 -112
  34. package/dist/session-core-dial.d.ts +38 -0
  35. package/dist/session-core-handshake.d.ts +16 -1
  36. package/dist/session-core-messages.d.ts +2 -2
  37. package/dist/session-core-reconnect.d.ts +2 -7
  38. package/dist/session-core.js +1 -1
  39. package/dist/session-resume-store.d.ts +43 -0
  40. package/dist/types.d.ts +1 -1
  41. package/dist/types.js +1 -1
  42. package/dist/use-user-transcript.d.ts +70 -0
  43. package/dist/use-workflow-form.d.ts +136 -0
  44. package/dist/use-workflow-progress.d.ts +100 -0
  45. package/dist/use-workflow-run.d.ts +56 -0
  46. package/dist/use-workflow-runs.d.ts +71 -0
  47. package/dist/workflow-client.d.ts +97 -0
  48. package/dist/workflow-events.d.ts +39 -0
  49. package/dist/worklets/playback-processor.d.ts +1 -1
  50. package/dist/worklets/playback-processor.js +8 -1
  51. package/package.json +9 -8
  52. package/dist/default-client/assets/audio-BqyrSHNq.js +0 -1
  53. package/dist/default-client/assets/index-Ctjrde3-.css +0 -2
  54. package/dist/default-client/assets/index-DfVI-qZ8.js +0 -293
  55. package/dist/{aai-logo-B8lDmsut.js → aai-logo-9xRBGVFl.js} +1 -1
  56. package/dist/{controls-DzQEKq9c.js → controls-Cy_YVfsa.js} +1 -1
  57. package/dist/{tool-call-block-CAscLGFy.js → tool-call-block-CrLN7xlI.js} +1 -1
package/dist/types.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS } from "@alexkroman1/aai";
1
+ import { CAPTURE_STOP_ACK_TIMEOUT_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, MIC_SILENCE_PROBE_MS, PLAYBACK_BUFFER_SECONDS, PLAYBACK_CONCEAL_FADE_MS, PLAYBACK_CONCEAL_FLOOR, PLAYBACK_DONE_MAX_WAIT_MS, PLAYBACK_DONE_POLL_MS, PLAYBACK_JITTER_MS, PLAYBACK_PROGRESS_INTERVAL_MS, PLAYBACK_REFILL_MS } from "@alexkroman1/aai/internal";
2
2
  //#region types.ts
3
3
  /**
4
4
  * `getUserMedia` audio constraints for every capture path in this package.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `useUserTranscript` — what the caller is saying RIGHT NOW, read correctly.
3
+ *
4
+ * `SessionSnapshot.userTranscript` is `string | null`, and the two falsy values
5
+ * mean different things:
6
+ *
7
+ * - `null` — nobody is speaking. There is no partial turn.
8
+ * - `""` — speech HAS been detected and no words have come back yet. A live
9
+ * session sits here for a few hundred milliseconds at the start of every turn.
10
+ *
11
+ * Read as one falsy check, those collapse and the indicator never appears at the
12
+ * start of a turn — which is the moment it is for. So every custom chrome writes
13
+ * `transcript !== null && (transcript === "" ? "…" : transcript)`, three
14
+ * templates did exactly that, and each one re-derived a protocol distinction
15
+ * from the type rather than from anything that told them.
16
+ *
17
+ * This is the same distinction as two named booleans, so a component can say
18
+ * what it means: render on `speaking`, show `text`, and use the SDK's own
19
+ * placeholder when there is nothing to show yet.
20
+ */
21
+ /**
22
+ * Placeholder for "listening, no words yet" — the `""` case above.
23
+ *
24
+ * A one-character ellipsis rather than three dots, because it is read by a
25
+ * screen reader as an ellipsis and it does not reflow the row when the first
26
+ * real word replaces it.
27
+ *
28
+ * @public
29
+ */
30
+ export declare const TRANSCRIBING_PLACEHOLDER = "\u2026";
31
+ /** What {@link useUserTranscript} returns. */
32
+ export interface UseUserTranscriptResult {
33
+ /**
34
+ * True while the caller holds the turn — from speech detection to the final
35
+ * transcript. This is the flag a live-transcript row renders on.
36
+ */
37
+ speaking: boolean;
38
+ /**
39
+ * The words so far, or {@link TRANSCRIBING_PLACEHOLDER} while there are none.
40
+ * Empty string when nobody is speaking.
41
+ */
42
+ text: string;
43
+ /**
44
+ * The raw partial: the words so far, `""` while there are none, and `null`
45
+ * when nobody is speaking. For a chrome that wants to render its own
46
+ * placeholder (or none).
47
+ */
48
+ partial: string | null;
49
+ }
50
+ /**
51
+ * Subscribe to the caller's in-progress turn.
52
+ *
53
+ * Narrowly subscribed — a component using this re-renders at STT-partial rate,
54
+ * which is exactly what it is for and exactly what a whole-page `useSession()`
55
+ * should not do.
56
+ *
57
+ * @example
58
+ * ```tsx
59
+ * import { useUserTranscript } from "@alexkroman1/aai-ui";
60
+ *
61
+ * function LiveTranscript() {
62
+ * const { speaking, text } = useUserTranscript();
63
+ * if (!speaking) return null;
64
+ * return <div className="italic opacity-60">{text}</div>;
65
+ * }
66
+ * ```
67
+ *
68
+ * @public
69
+ */
70
+ export declare function useUserTranscript(): UseUserTranscriptResult;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The two hooks a FORM needs, as against the one a status view does.
3
+ *
4
+ * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
5
+ * These two are what comes before it: `useWorkflows` reads the declared
6
+ * workflows so `<WorkflowFields>` can render a form from a schema, and
7
+ * `useWorkflowSubmit` starts a run and hands the id straight to
8
+ * `useWorkflowRun`.
9
+ *
10
+ * ## `useWorkflowSubmit` — a form's two halves in one hook
11
+ *
12
+ * A page that submits a workflow always needs the same four pieces of state:
13
+ * the run id, whether a submit is in flight, whether the RUN is still going, and
14
+ * whichever of the two failed. `link-digest` writes them out by hand, which is
15
+ * the right shape for a template teaching the primitives and the wrong shape to
16
+ * write a third time — and it is easy to get subtly wrong: dropping the previous
17
+ * run id before the new `POST` returns is what stops a finished result sitting
18
+ * under a form that is already submitting again.
19
+ *
20
+ * So this is `api.start` plus {@link useWorkflowRun}, with the state between
21
+ * them. It adds no transport of its own and holds no run state of its own; the
22
+ * watching (stream first, poll as its fallback, terminal stops) is entirely
23
+ * `useWorkflowRun`'s, and `run` here IS its run.
24
+ *
25
+ * ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
26
+ *
27
+ * `api.startAndWait` would collapse this to one request, and it is the wrong
28
+ * default for a page: it holds a socket open for up to a minute, answers nothing
29
+ * until it settles, and a page has `useWorkflowRun` — which survives a reload,
30
+ * shows progress, and costs one stream. The synchronous call is for callers with
31
+ * nowhere to put a watch (a script, a cron, a form POST from a server). Pass
32
+ * `wait` here when the page really does want one request, and the run is
33
+ * followed from the same id either way.
34
+ */
35
+ import { type WorkflowSummary } from "@alexkroman1/aai";
36
+ import type { WorkflowApi, WorkflowRun } from "./workflow-client.ts";
37
+ /** Options for {@link useWorkflows}. */
38
+ export type UseWorkflowsOptions = {
39
+ /** The client to read the listing with. Defaults to one for the page's own agent. */
40
+ api?: WorkflowApi;
41
+ /**
42
+ * Skip the lookup entirely, reporting an empty listing that is not loading.
43
+ *
44
+ * For a caller that may or may not need the listing and cannot decide with a
45
+ * conditional hook — `<WorkflowFields>` handed a summary rather than a name is
46
+ * the one in this package. It reports `loading: false`, because a skipped
47
+ * lookup is finished rather than pending.
48
+ */
49
+ skip?: boolean;
50
+ };
51
+ /** What {@link useWorkflows} reports. */
52
+ export type UseWorkflowsResult = {
53
+ /** The agent's declared workflows, each with the JSON Schema of its input. */
54
+ workflows: WorkflowSummary[];
55
+ /** True until the listing lands, so a form can hold its fields back. */
56
+ loading: boolean;
57
+ /** The lookup's failure. Set alongside an EMPTY list, which is why it exists. */
58
+ error: string | undefined;
59
+ };
60
+ /**
61
+ * Read the agent's declared workflows.
62
+ *
63
+ * What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
64
+ * Schema of that workflow's input, converted server-side precisely so a browser
65
+ * can read it.
66
+ *
67
+ * The failure is reported rather than swallowed, because the alternative is an
68
+ * empty list — which renders as a form with no fields and reads as "this agent
69
+ * declares no workflows" about an agent that was merely unreachable.
70
+ *
71
+ * @public
72
+ */
73
+ export declare function useWorkflows(opts?: UseWorkflowsOptions): UseWorkflowsResult;
74
+ /** What {@link useWorkflowSubmit} returns. */
75
+ export type WorkflowSubmission<R = unknown> = {
76
+ /**
77
+ * Start a run with this input. Resolves once the run EXISTS — progress
78
+ * arrives through `run` — so a `<Form>`'s handler can await it to know the
79
+ * submission was accepted.
80
+ */
81
+ submit: (input: unknown) => Promise<void>;
82
+ /** Clear the run and any error, putting the form back to its initial state. */
83
+ reset: () => void;
84
+ /** The run, once started, followed to completion. */
85
+ run: WorkflowRun<R> | undefined;
86
+ /**
87
+ * True from `submit()` until the run reaches a terminal status.
88
+ *
89
+ * The WORK, not the request: a run outlives its `POST`, and a submit button
90
+ * that re-enabled on the response would invite a second submission of work
91
+ * already in flight.
92
+ */
93
+ pending: boolean;
94
+ /** The submit's own failure (a rejected input), or the watch's. */
95
+ error: string | undefined;
96
+ };
97
+ /** Options for {@link useWorkflowSubmit}. */
98
+ export type UseWorkflowSubmitOptions = {
99
+ /** The client to start runs with. Defaults to one for the page's own agent. */
100
+ api?: WorkflowApi;
101
+ /** Correlation key recorded with the run, for finding it again without the id. */
102
+ key?: string;
103
+ /**
104
+ * Hold the `POST` open until the run settles, up to this many ms — the
105
+ * synchronous mode. Omitted (the default) returns as soon as the run exists.
106
+ */
107
+ wait?: number;
108
+ /** How often the fallback poll re-reads a live run. */
109
+ intervalMs?: number;
110
+ };
111
+ /**
112
+ * Start a workflow from a form, and follow the run it creates.
113
+ *
114
+ * @typeParam R - The workflow's output type, which is what makes
115
+ * `run.status === "completed"` narrow to a typed `run.output`. Derive it with
116
+ * `WorkflowOutputOf<typeof myWorkflow>`.
117
+ *
118
+ * @example
119
+ * ```tsx
120
+ * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
121
+ *
122
+ * function DigestForm() {
123
+ * const { submit, run, pending, error } = useWorkflowSubmit("digest");
124
+ * return (
125
+ * <Form onSubmit={(values) => submit(values)} error={error}>
126
+ * <TextField name="url" label="Link" type="url" required />
127
+ * <SubmitButton pending={pending}>Digest</SubmitButton>
128
+ * {run?.status === "completed" && <p>Done.</p>}
129
+ * </Form>
130
+ * );
131
+ * }
132
+ * ```
133
+ *
134
+ * @public
135
+ */
136
+ export declare function useWorkflowSubmit<R = unknown>(workflow: string, opts?: UseWorkflowSubmitOptions): WorkflowSubmission<R>;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `useWorkflowProgress` — read what a run has WRITTEN while it runs.
3
+ *
4
+ * The sibling of `useWorkflowRun`, and the split between them is the whole
5
+ * reason this exists. That hook reports a run's STATE: the status transitions
6
+ * the world records, which every run has. This reports what the run itself
7
+ * wrote through `getWritable()`, which is the only thing a long run can say
8
+ * before it finishes — a snapshot carries a status and, once terminal, an
9
+ * output, and nothing in between. A page that shows only status shows
10
+ * "Working…" for ten minutes and then a result.
11
+ *
12
+ * ## There is no poll fallback, and that is not an omission
13
+ *
14
+ * `useWorkflowRun` degrades to polling `GET /runs/:id` because a run's STATE is
15
+ * readable that way. A run's written chunks are not: the stream is the only
16
+ * route to them, so an agent that does not serve it has no progress to give and
17
+ * `supported` says so once, rather than a poll pretending to look for something
18
+ * that is not there. A page renders its status line either way.
19
+ *
20
+ * ## Chunks are RETAINED, so this is a replay as much as a tail
21
+ *
22
+ * The run's stream keeps every chunk, so a page that mounts late — a reload, a
23
+ * second tab, a link opened tomorrow — reads the whole history from index 0 and
24
+ * arrives at the same list as one that watched throughout. That is what makes a
25
+ * durable run's progress durable too, and it is why the default `startIndex` is
26
+ * 0 rather than "from now": a tail-only default would make the same page show
27
+ * different things depending on when it opened.
28
+ *
29
+ * ## It RE-OPENS while the run is live, because a progress read is bounded
30
+ *
31
+ * The route answers with the chunks written when the request arrived and then
32
+ * ends, reporting `complete` — whether the run itself was terminal. It has to:
33
+ * a workflow stream signals its end only once CLOSED, and a progress channel
34
+ * written by one step after another is never closed, so a read that waited for
35
+ * the end would hang forever on a finished run. (It did: see the route's own
36
+ * doc.)
37
+ *
38
+ * So this hook re-opens from where it left off until a read comes back
39
+ * `complete`. That is a poll, and the honest description of progress is a durable
40
+ * log rather than a socket — but it is a poll of a CHEAP shape: each read asks
41
+ * only for chunks past the last index it saw, so a quiet run costs an empty
42
+ * answer rather than the whole log again.
43
+ */
44
+ import type { WorkflowApi } from "./workflow-client.ts";
45
+ /** The slice of the client this needs: one method. */
46
+ export type RunProgressReader = Pick<WorkflowApi, "streamOutput">;
47
+ export type UseWorkflowProgressResult<T = string> = {
48
+ /** Every chunk the run has written, oldest first. */
49
+ progress: T[];
50
+ /** The newest chunk, or undefined before the first one lands. */
51
+ latest: T | undefined;
52
+ /** True while the run is still being read — it may yet say more. */
53
+ streaming: boolean;
54
+ /**
55
+ * False once the agent has answered that it does not serve this route.
56
+ *
57
+ * Distinguishes "this deploy predates progress streams" from "the run has not
58
+ * written anything yet", which look identical from `progress` alone. A page
59
+ * uses it to hide the section rather than show an empty one forever.
60
+ */
61
+ supported: boolean;
62
+ };
63
+ /** How often a live run's progress is re-read once a bounded read has ended. */
64
+ export declare const DEFAULT_PROGRESS_POLL_MS = 1000;
65
+ /**
66
+ * Follow one run's progress stream.
67
+ *
68
+ * Passing `undefined` (nothing started yet) costs nothing, and reading stops for
69
+ * good once a read reports the run terminal — so a finished run costs one read.
70
+ *
71
+ * @example
72
+ * ```tsx
73
+ * import { useWorkflowProgress } from "@alexkroman1/aai-ui";
74
+ *
75
+ * function Progress({ runId }: { runId?: string }) {
76
+ * const { progress, streaming, supported } = useWorkflowProgress(runId);
77
+ * if (!supported) return null;
78
+ * return (
79
+ * <pre>
80
+ * {progress.join("\n")}
81
+ * {streaming && "\n…"}
82
+ * </pre>
83
+ * );
84
+ * }
85
+ * ```
86
+ *
87
+ * @typeParam T - What the workflow writes. Defaults to `string`, which is what
88
+ * a progress channel usually carries; a workflow writing objects names its own
89
+ * shape. Nothing in the browser can verify it — the route describes no type —
90
+ * so this is the page's assertion about its own agent, narrowed once here
91
+ * rather than at every read.
92
+ *
93
+ * @public
94
+ */
95
+ export declare function useWorkflowProgress<T = string>(runId: string | undefined, opts?: {
96
+ api?: WorkflowApi;
97
+ namespace?: string;
98
+ startIndex?: number;
99
+ intervalMs?: number;
100
+ }): UseWorkflowProgressResult<T>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `useWorkflowRun` — watch one run until it settles.
3
+ *
4
+ * Split from `workflow-client.ts` on the seam that module's doc already draws:
5
+ * everything there is a REQUEST (one call, one answer, no React), and everything
6
+ * here is the loop that keeps asking. They are read for different reasons — the
7
+ * client is what a script or a `curl` equivalent needs, this is what a page needs
8
+ * — and only this half imports React.
9
+ *
10
+ * `workflow-events.ts` sits under it as the streaming fast path, and
11
+ * `use-workflow-form.ts` above it as the form-shaped caller.
12
+ */
13
+ import type { WorkflowApi, WorkflowRun } from "./workflow-client.ts";
14
+ /** How often {@link useWorkflowRun} re-reads a live run when it has to poll. */
15
+ export declare const DEFAULT_WORKFLOW_POLL_MS = 2000;
16
+ /**
17
+ * Consecutive "no such run" reads {@link useWorkflowRun} tolerates before giving
18
+ * up on the id.
19
+ *
20
+ * Small on purpose: a 404 is a stable answer, so the budget exists only to
21
+ * absorb a first read that races the run's creation — not to keep hoping.
22
+ * Unbounded, a stale id polls (and, on the platform, BROKERS) for as long as the
23
+ * tab is open.
24
+ */
25
+ export declare const MAX_MISSING_READS = 3;
26
+ export type UseWorkflowRunResult<R = unknown> = {
27
+ /** Latest snapshot, or undefined before the first read lands. */
28
+ run: WorkflowRun<R> | undefined;
29
+ /** The last read's failure, cleared by the next successful one. */
30
+ error: string | undefined;
31
+ /** True while a non-terminal run is still being watched. */
32
+ polling: boolean;
33
+ };
34
+ /**
35
+ * Watch one run until it reaches a terminal status.
36
+ *
37
+ * A watch rather than a subscription because a run is durable and the page is
38
+ * not: it can complete while the tab is closed, on a different sandbox, hours
39
+ * later. There is no session to reconnect — the id is the whole state.
40
+ *
41
+ * The stream (`GET /runs/:id/events`) is tried first and the poll is its
42
+ * fallback, so an agent deployed before that route existed still works. Watching
43
+ * STOPS on a terminal status, so a finished run costs nothing; passing
44
+ * `undefined` (nothing started yet) also costs nothing.
45
+ *
46
+ * @typeParam R - The workflow's output type. Supplying it is what makes
47
+ * `run.status === "completed"` narrow to a typed `run.output` instead of
48
+ * `unknown`. Derive it with `WorkflowOutputOf<typeof myWorkflow>` — a
49
+ * type-only import of `agent.ts` is erased, so it costs the bundle nothing.
50
+ *
51
+ * @public
52
+ */
53
+ export declare function useWorkflowRun<R = unknown>(runId: string | undefined, opts?: {
54
+ api?: WorkflowApi;
55
+ intervalMs?: number;
56
+ }): UseWorkflowRunResult<R>;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The RUNS a workflow has had — the list a page shows beside its form.
3
+ *
4
+ * `useWorkflowRun` watches one run you already hold an id for, which is the
5
+ * right shape for the run a page just started and the wrong one for everything
6
+ * before it. A workflow app that only offers that leaves its own history
7
+ * unreachable: a page reload drops the id, and the only way back to yesterday's
8
+ * transcript is to have written the id down — which is what
9
+ * `transcription-workflow` asked people to do, with a text box for pasting one.
10
+ *
11
+ * `GET /workflows/runs?workflow=…` has always been able to answer this. This is
12
+ * the hook over it, so a page renders history instead of asking for an id.
13
+ *
14
+ * ## It re-reads on demand, and does not poll on its own
15
+ *
16
+ * A list is not a live view: the run a page cares about right now is already
17
+ * being watched by `useWorkflowRun`, and a second polling loop over the whole
18
+ * history would broker N requests a minute on the platform to re-learn what the
19
+ * first one already knows. So this reads once and hands back `refresh` — which
20
+ * a page calls when its own run settles, which is exactly when the list is
21
+ * stale.
22
+ */
23
+ import type { WorkflowApi, WorkflowRun } from "./workflow-client.ts";
24
+ /** Options for {@link useWorkflowRuns}. */
25
+ export type UseWorkflowRunsOptions = {
26
+ /** The client to read with. Defaults to one for the page's own agent. */
27
+ api?: WorkflowApi;
28
+ /** Most runs to return, newest first. The agent clamps its own ceiling. */
29
+ limit?: number;
30
+ /**
31
+ * Narrow to one correlation key — the `key` a run was started with.
32
+ *
33
+ * Omitted, the list is every recent run of the workflow, which is what an
34
+ * operator's page wants. A page showing "your" runs passes the key it started
35
+ * them with; there is no per-user filtering behind this, so the key IS the
36
+ * scoping mechanism.
37
+ */
38
+ key?: string;
39
+ /** Skip the read entirely — for a page that does not know its workflow yet. */
40
+ skip?: boolean;
41
+ };
42
+ /** What {@link useWorkflowRuns} reports. */
43
+ export type UseWorkflowRunsResult<R = unknown> = {
44
+ /** The runs, newest first. Empty until the first read lands. */
45
+ runs: WorkflowRun<R>[];
46
+ /** True until the first read settles, and during an explicit refresh. */
47
+ loading: boolean;
48
+ /** The read's failure, alongside an empty list — which is why it exists. */
49
+ error: string | undefined;
50
+ /** Re-read now. Call it when a run this page started reaches a terminal status. */
51
+ refresh: () => void;
52
+ };
53
+ /**
54
+ * Read a workflow's recent runs.
55
+ *
56
+ * @typeParam R - The workflow's output type, so a completed run's `output` is
57
+ * typed rather than `unknown`. Derive it with `WorkflowOutputOf`.
58
+ *
59
+ * @example
60
+ * ```tsx
61
+ * import { useWorkflowRuns } from "@alexkroman1/aai-ui";
62
+ *
63
+ * function History() {
64
+ * const { runs } = useWorkflowRuns("transcribe", { limit: 10 });
65
+ * return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;
66
+ * }
67
+ * ```
68
+ *
69
+ * @public
70
+ */
71
+ export declare function useWorkflowRuns<R = unknown>(workflow: string | undefined, opts?: UseWorkflowRunsOptions): UseWorkflowRunsResult<R>;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Browser client for the workflow HTTP API (`aai/host/workflow-api.ts`).
3
+ *
4
+ * This is the whole client half of a WORKFLOW APP: an agent whose front door is
5
+ * a form rather than a microphone (`workflowApp()`) starts runs
6
+ * here and watches them for the answer. It deliberately does NOT go through
7
+ * `SessionCore` — there is no socket, no audio graph, and no session to resume.
8
+ *
9
+ * **The requests themselves are the SDK's now**
10
+ * (`createWorkflowApiClient`, `@alexkroman1/aai/workflow-api`), and what is left
11
+ * here is the one thing that is genuinely a BROWSER's: the default base URL.
12
+ * Every route, every query, the 404-is-an-answer rule and the `wait` clamp were
13
+ * written three times over — here, in the studio's Workflows card, and in
14
+ * `aai workflow` — and the parts the copies disagreed on were exactly the ones a
15
+ * reader cannot check by eye. The SDK module's doc carries that argument; this
16
+ * file must not grow a second implementation of any of it.
17
+ *
18
+ * The one thing worth knowing before using it: **a run outlives the page.**
19
+ * Starting one resolves as soon as the run is created, so `runId` is the only
20
+ * handle that matters and it stays valid across a reload, a different device, or
21
+ * `curl` — which is what makes `useWorkflowRun` a watch rather than a
22
+ * subscription to something the page owns.
23
+ *
24
+ * The loop that keeps asking lives in `use-workflow-run.ts`, and the streaming
25
+ * fast path under it in `workflow-events.ts`.
26
+ */
27
+ import type { WorkflowRunSnapshot } from "@alexkroman1/aai";
28
+ import { type WorkflowApi } from "@alexkroman1/aai/workflow-api";
29
+ /**
30
+ * A run's observable state.
31
+ *
32
+ * Aliased from the SDK rather than restated. `import type` is erased entirely,
33
+ * so a second definition of the fields and the five-member status union would
34
+ * buy nothing and cost the one thing that matters — nothing would assert the two
35
+ * agree, so a status added to the SDK would never reach the browser type.
36
+ *
37
+ * `WorkflowRun` keeps the shorter name because it is what a page's own code
38
+ * writes; nothing in a browser needs the word "snapshot" to know a read returns
39
+ * one.
40
+ *
41
+ * It is GENERIC on the run's output, and a page supplies it — see
42
+ * {@link useWorkflowRun}. It does NOT have to restate that type: a page can name
43
+ * its own workflow and derive the rest with `WorkflowOutputOf`, pulling no
44
+ * server graph into the bundle.
45
+ *
46
+ * @public
47
+ */
48
+ export type WorkflowRun<R = unknown> = WorkflowRunSnapshot<R>;
49
+ /**
50
+ * A workflow's own output type, and the shape `GET /workflows` lists — both
51
+ * re-exported so a page needs ONE import to type its runs and render its form.
52
+ */
53
+ export type { WorkflowOutputOf, WorkflowSummary } from "@alexkroman1/aai";
54
+ /**
55
+ * A run status nothing will change again.
56
+ *
57
+ * Re-exported from the SDK rather than defined here. A second implementation
58
+ * listing two of the three terminal statuses would leave a cancelled run polled
59
+ * forever by a page while the agent considered it finished — the kind of drift a
60
+ * status predicate beside the status union cannot have.
61
+ */
62
+ export { isTerminal } from "@alexkroman1/aai";
63
+ /**
64
+ * The call set {@link createWorkflowApi} returns.
65
+ *
66
+ * Re-exported from the SDK rather than declared here: it IS the SDK's client,
67
+ * and a structural restatement would be a second thing to keep in step with the
68
+ * routes for no gain.
69
+ */
70
+ export type { WorkflowApi } from "@alexkroman1/aai/workflow-api";
71
+ export type WorkflowApiOptions = {
72
+ /**
73
+ * Base URL of the agent. Defaults to the page's own origin + path, which is
74
+ * right for a page the agent itself serves — the only case that exists today,
75
+ * and the reason this wrapper exists at all: the SDK client requires a base
76
+ * URL, because `location` does not exist in that half of the SDK.
77
+ */
78
+ baseUrl?: string;
79
+ /**
80
+ * Bearer for an agent whose operator set `AAI_WORKFLOW_API_TOKEN`. A page
81
+ * served to the public has nothing to put here (and should not — it would be
82
+ * readable in the bundle); this exists for a programmatic caller written
83
+ * against the same client.
84
+ */
85
+ token?: string;
86
+ };
87
+ /**
88
+ * Create a workflow API client aimed at the agent serving this page.
89
+ *
90
+ * Hoist it out of the component that uses it. `useWorkflowRun` holds the client
91
+ * in a ref precisely so a fresh object per render does not restart its watch,
92
+ * but a client built in render is still a new `fetch` closure every time and
93
+ * reads as though it were free.
94
+ *
95
+ * @public
96
+ */
97
+ export declare function createWorkflowApi(opts?: WorkflowApiOptions): WorkflowApi;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Watching a run over server-sent events — the PUSH half of `useWorkflowRun`.
3
+ *
4
+ * Its own module because the seam is clean: everything in `workflow-client.ts`
5
+ * is request/response shaping plus the poll, and this is one long-lived stream
6
+ * and the SSE parser it needs.
7
+ *
8
+ * @internal
9
+ */
10
+ import type { WorkflowApi, WorkflowRun } from "./workflow-client.ts";
11
+ /**
12
+ * The slice of the client this needs: one method.
13
+ *
14
+ * Narrowed rather than taking the whole `WorkflowApi`, and the narrowing is the
15
+ * honest statement — nothing here reads a run, starts one, or cancels one, it
16
+ * opens ONE stream. It also makes a test double a plain object rather than a
17
+ * six-method stub cast into shape. A real client satisfies it structurally.
18
+ */
19
+ export type RunWatcher = Pick<WorkflowApi, "watch">;
20
+ /**
21
+ * Watch a run over SSE, falling back to the caller's poll on any failure.
22
+ *
23
+ * The poll stays the fallback rather than being replaced, and that is the whole
24
+ * shape of this: a stream is an optimisation over a mechanism that already
25
+ * works, so every way it can fail — an older agent with no `/events` route, a
26
+ * proxy that buffers, a network that drops it — has to degrade to the thing that
27
+ * does. What it buys is real, though: on the platform every polled read BROKERS,
28
+ * so N open tabs at `DEFAULT_WORKFLOW_POLL_MS` is N/2 brokered requests a
29
+ * second, each able to boot a sandbox. One stream per tab replaces all of it.
30
+ *
31
+ * `EventSource` is not used, for two reasons that both matter here: it cannot
32
+ * send an `Authorization` header (an agent with `AAI_WORKFLOW_API_TOKEN` set
33
+ * would be unreachable), and it reconnects on its own schedule, which would
34
+ * fight the caller's. A `fetch` stream gives both back.
35
+ *
36
+ * Returns a stop function. `onFallback` is called at most once, when this stream
37
+ * cannot be relied on and the poll should take over.
38
+ */
39
+ export declare function watchRunEvents<R>(getClient: () => RunWatcher, runId: string, onRun: (run: WorkflowRun<R>) => void, onSettled: () => void, onFallback: () => void): () => void;
@@ -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.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 // Fill target for the start of a turn. If 'done' arrives first (short\n // utterance), start immediately instead of waiting for audio that is\n // never coming.\n this.jitterSamples = Math.floor((rate * (opts.jitterMs ?? 400)) / 1000);\n // Fill target after an underrun \u2014 see PLAYBACK_REFILL_MS.\n this.refillSamples = Math.floor((rate * (opts.refillMs ?? 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 this.fillTarget = this.jitterSamples;\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 / sampleRate) * 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.fillTarget || 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 (at the refill target) 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.fillTarget = this.refillSamples;\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";
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.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 // Fill target for the start of a turn. If 'done' arrives first (short\n // utterance), start immediately instead of waiting for audio that is\n // never coming.\n this.jitterSamples = Math.floor((rate * (opts.jitterMs ?? 400)) / 1000);\n // Fill target after an underrun \u2014 see PLAYBACK_REFILL_MS.\n this.refillSamples = Math.floor((rate * (opts.refillMs ?? 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 this.fillTarget = this.jitterSamples;\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.fillTarget || 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 (at the refill target) 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.fillTarget = this.refillSamples;\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
3
  declare const _default: string;
4
4
  export default _default;