@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.
- package/README.md +2 -1
- package/dist/_repeat-until.d.ts +30 -0
- package/dist/_sse.d.ts +56 -0
- package/dist/_workflow-api-ref.d.ts +37 -0
- package/dist/audio.js +26 -26
- package/dist/{chat-view-DadZOvJO.js → chat-view-CK61bWWx.js} +4 -3
- package/dist/components/_form-values.d.ts +19 -0
- package/dist/components/auto-scroll.d.ts +63 -0
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.js +1 -1
- package/dist/components/form-types.d.ts +67 -0
- package/dist/components/form.d.ts +138 -0
- package/dist/components/message-list.js +1 -1
- package/dist/components/start-screen.js +1 -1
- package/dist/components/tool-call-block.js +1 -1
- package/dist/components/workflow-fields.d.ts +57 -0
- package/dist/components/workflow-progress.d.ts +55 -0
- package/dist/default-client/assets/audio-fO7SVU64.js +1 -0
- package/dist/default-client/assets/{capture-processor-CWRLCPS3.js → capture-processor-Dmc-KEpb.js} +4 -4
- package/dist/default-client/assets/client-audio-constants-Ck0IJO4c.js +1 -0
- package/dist/default-client/assets/index-4u908fff.js +293 -0
- package/dist/default-client/assets/index-CDugAuLK.css +2 -0
- package/dist/default-client/assets/{playback-processor-SKKE9qu2.js → playback-processor-DUsmALNH.js} +10 -3
- package/dist/default-client/index.html +3 -2
- package/dist/define-client.d.ts +40 -1
- package/dist/define-client.js +61 -19
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.js +4 -26
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1597 -7
- package/dist/{message-list-YdLocGoT.js → message-list-BwA3rdPi.js} +105 -27
- package/dist/page.d.ts +88 -0
- package/dist/{session-core-BA8H3qtF.js → session-core-ClKdVgRU.js} +245 -112
- package/dist/session-core-dial.d.ts +38 -0
- package/dist/session-core-handshake.d.ts +16 -1
- package/dist/session-core-messages.d.ts +2 -2
- package/dist/session-core-reconnect.d.ts +2 -7
- package/dist/session-core.js +1 -1
- package/dist/session-resume-store.d.ts +43 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/use-user-transcript.d.ts +70 -0
- package/dist/use-workflow-form.d.ts +136 -0
- package/dist/use-workflow-progress.d.ts +100 -0
- package/dist/use-workflow-run.d.ts +56 -0
- package/dist/use-workflow-runs.d.ts +71 -0
- package/dist/workflow-client.d.ts +97 -0
- package/dist/workflow-events.d.ts +39 -0
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +8 -1
- package/package.json +9 -8
- package/dist/default-client/assets/audio-BqyrSHNq.js +0 -1
- package/dist/default-client/assets/index-Ctjrde3-.css +0 -2
- package/dist/default-client/assets/index-DfVI-qZ8.js +0 -293
- package/dist/{aai-logo-B8lDmsut.js → aai-logo-9xRBGVFl.js} +1 -1
- package/dist/{controls-DzQEKq9c.js → controls-Cy_YVfsa.js} +1 -1
- 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;
|