@alexkroman1/aai-ui 0.14.1 → 0.14.3

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.
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The bridge from an XState actor to `useSyncExternalStore`: the one shape
3
+ * `_tap-to-talk-state.ts` and `_workflow-form-state.ts` hand their hooks.
4
+ * `read` must return the SAME object while nothing it projects has changed —
5
+ * `useSyncExternalStore` re-renders on every new reference.
6
+ */
7
+ import type { Actor, AnyStateMachine, EventFromLogic } from "xstate";
8
+ /** A started machine, as a React hook subscribes to it. */
9
+ export type ActorStore<Event, View> = {
10
+ send(event: Event): void;
11
+ getView(): View;
12
+ subscribe(listener: () => void): () => void;
13
+ stop(): void;
14
+ };
15
+ /** Start `actor` and expose it through `read`, its memoized projection. */
16
+ export declare function actorStore<Machine extends AnyStateMachine, View>(actor: Actor<Machine>, read: () => View): ActorStore<EventFromLogic<Machine>, View>;
@@ -10,7 +10,10 @@
10
10
  * `StartOptions.key` is the handle that survives that, and it always was — a
11
11
  * caller's own name for a run, indexed by the agent, read back with
12
12
  * `find(workflow, key)`. What was missing is the two lines that ASK. This is
13
- * them, plus the four decisions they turn out to carry.
13
+ * them, plus the four decisions they turn out to carry. The question lives
14
+ * here; the decisions about WHEN are positions of the form's statechart
15
+ * (`_workflow-form-state.ts`), where this lookup is the `recovering` state's
16
+ * invoke.
14
17
  *
15
18
  * ## It is a MOUNT-time act, not "whenever there is no run"
16
19
  *
@@ -24,9 +27,10 @@
24
27
  * ## The lookup NEVER wins a race against a submit
25
28
  *
26
29
  * A person who reloads and immediately submits has started the run they want,
27
- * and an answer that was already in flight names an older one. The caller
28
- * therefore adopts through `current ?? found`: the recovered id fills an empty
29
- * slot and never replaces a full one.
30
+ * and an answer that was already in flight names an older one. A `SUBMIT` (or a
31
+ * `RESET`) leaves `recovering`, which stops the lookup, so the late answer is
32
+ * dropped by the machine rather than reconciled against the run that replaced
33
+ * it.
30
34
  *
31
35
  * ## A failed lookup is REPORTED
32
36
  *
@@ -51,7 +55,7 @@
51
55
  * always open empty says so, and then nothing here runs.
52
56
  */
53
57
  import type { WorkflowApi } from "./workflow-client.ts";
54
- /** What {@link useRecoveredRun} needs. */
58
+ /** What a page's recovery needs; `_submission-state.ts` turns it into the lookup. */
55
59
  export type RecoverRunOptions = {
56
60
  /** The workflow whose runs are indexed under `key`. */
57
61
  workflow: string;
@@ -61,19 +65,11 @@ export type RecoverRunOptions = {
61
65
  enabled: boolean;
62
66
  /** The stable getter from `useWorkflowApiRef`. */
63
67
  getClient: () => WorkflowApi;
64
- /** Adopt this run. Called at most once, and never with an empty answer. */
65
- onFound: (runId: string) => void;
66
- /** The lookup failed, and the page has to say so. */
67
- onError: (message: string) => void;
68
68
  };
69
69
  /**
70
- * Look up the newest run for a key, once, as the component mounts.
70
+ * Look up the newest run for a key.
71
71
  *
72
- * @param opts - See {@link RecoverRunOptions}.
73
- * @returns Whether the lookup is still out. A caller folds it into its own
74
- * `pending`, because a form offering Submit while a live run is arriving is a
75
- * form inviting a second one.
76
- *
77
- * @internal
72
+ * @returns The run's id, or `undefined` for a key with no runs. A failure
73
+ * REJECTS, so the machine can report it — see "A failed lookup is REPORTED".
78
74
  */
79
- export declare function useRecoveredRun(opts: RecoverRunOptions): boolean;
75
+ export declare function findRecoveredRun(client: WorkflowApi, workflow: string, key: string): Promise<string | undefined>;
@@ -1,73 +1,41 @@
1
1
  /**
2
- * The state a form submission carries, for both hooks that make one.
2
+ * The state a form submission carries, for both hooks that make one: the
3
+ * form's statechart (`_workflow-form-state.ts`) bridged into React with
4
+ * `useSyncExternalStore`, the way `use-tap-to-talk.ts` bridges its own.
3
5
  *
4
6
  * `useWorkflowSubmit` and `useWorkflowStream` stay separate hooks — they differ
5
7
  * in WHEN the run is created relative to the bytes, which is the whole reason
6
- * the streaming one exists — but everything around that ordering was written
7
- * twice: the same four `useState`s, the same ref holding the live submission,
8
- * the same prologue, the same superseded-submission guard, the same `reset`,
9
- * and the same pause pair. Roughly forty-five lines and thirty of comment.
8
+ * the streaming one exists — but everything around that ordering is shared:
9
+ * the run id, the submission's own failure, the bar, `startedHere`, the
10
+ * supersede rule, `reset` and the pause pair. Each hook still owns its own
11
+ * submission BODY, handed to {@link SubmissionActions.submit}.
10
12
  *
11
- * The copy had already diverged in ways nobody chose, which is the argument for
12
- * this module rather than for tidiness. What is here is only the scaffold; each
13
- * hook still owns its own `submit` body.
14
- *
15
- * ## The superseded-submission rule
16
- *
17
- * A submission that has been replaced owns none of this state any more. Its
18
- * walk unwinds AFTER the next one has already set `starting`, so clearing
19
- * unconditionally in a `finally` would report the live submission as finished
20
- * and drop the bar it is drawing. {@link SubmissionState.end} is that check,
21
- * made once here instead of at each hook's own `finally`.
13
+ * Nothing here decides anything. The decisions are positions of the machine,
14
+ * which is why this module holds no token, no ref to a live submission, and no
15
+ * "is this still the current one?" check.
22
16
  */
23
- import type { Dispatch, SetStateAction } from "react";
17
+ import { type RecoverRunOptions } from "./_recover-run.ts";
18
+ import { type SubmissionWork, type WorkflowFormView } from "./_workflow-form-state.ts";
24
19
  import type { UploadGate } from "./upload/index.ts";
25
- import type { UploadStatus } from "./use-workflow-form.ts";
26
20
  /**
27
- * What a hook installs as its live submission.
21
+ * The stable half: everything a hook's `submit` and its returned controls call.
28
22
  *
29
- * Generic over the token because the two hooks hold different things —
30
- * `useWorkflowSubmit` an `UploadSession` (the walk needs its id maps),
31
- * `useWorkflowStream` a bare gate in a wrapper — and all this module needs from
32
- * either is the gate, which is what a supersede and a `reset` cancel.
33
- */
34
- export type SubmissionToken = {
35
- gate: UploadGate;
36
- };
37
- /**
38
- * The stable half: everything a `submit` body calls.
39
- *
40
- * Split from the values deliberately. A `submit` is a `useCallback` and this is
23
+ * Split from the view deliberately. A `submit` is a `useCallback` and this is
41
24
  * one of its dependencies, so a bag that changed identity whenever `upload`
42
25
  * changed would rebuild `submit` — and with it the `onSubmit` handed to
43
- * `<Form>` — on every progress report. Every member here is a `useState`
44
- * setter or a `useCallback` with no changing dependency, so the bag is stable
45
- * for the component's life.
26
+ * `<Form>` — on every progress report. Every member reaches the machine
27
+ * through a ref, so the bag is stable for the component's life.
46
28
  */
47
- export type SubmissionActions<S extends SubmissionToken> = {
29
+ export type SubmissionActions = {
48
30
  /**
49
- * The full dispatch, not a plain setter: `useWorkflowSubmit`'s recovery
50
- * lookup writes `(current) => current ?? found`, so that a run the person has
51
- * already started this session is never displaced by one read back from the
52
- * key index.
53
- */
54
- setRunId: Dispatch<SetStateAction<string | undefined>>;
55
- setStartError: Dispatch<SetStateAction<string | undefined>>;
56
- setUpload: Dispatch<SetStateAction<UploadStatus | undefined>>;
57
- /**
58
- * Open a submission: supersede whatever was in flight and install `token`.
31
+ * Run `work` as THE submission, superseding whatever was in flight.
59
32
  *
60
- * Cancels the previous gate first, so a stale submission cannot park the new
61
- * one, and clears the previous run id BEFORE the request rather than when it
62
- * returns — a finished result sitting under a form that is already submitting
63
- * again is the one wrong answer this can give, and it looks like a right one.
64
- */
65
- begin: (token: S) => void;
66
- /**
67
- * Close `token`, if it is still the live one. See "The superseded-submission
68
- * rule" above.
33
+ * `gate` is the submission's pause, which `pauseUpload` reaches and leaving
34
+ * the submission cancels. Resolves when the submission is over — finished,
35
+ * failed, or abandoned by a later `submit` or a `reset` — and never rejects:
36
+ * a failure is reported through `startError`, the way a form expects.
69
37
  */
70
- end: (token: S) => void;
38
+ submit: (gate: UploadGate, work: SubmissionWork) => Promise<void>;
71
39
  /** Put the form back: abandon the bytes and drop the result. */
72
40
  reset: () => void;
73
41
  /** Park the bytes and say so on the bar. */
@@ -75,18 +43,14 @@ export type SubmissionActions<S extends SubmissionToken> = {
75
43
  /** Send the rest. */
76
44
  resumeUpload: () => void;
77
45
  };
78
- /** What {@link useSubmissionState} hands back. */
79
- export type SubmissionState<S extends SubmissionToken> = {
80
- /** The run this submission started, once it has one. */
81
- runId: string | undefined;
82
- /** Whether a submission is in flight — the POST and the bytes, not the run. */
83
- starting: boolean;
84
- /** The submission's own failure, as against the run's. */
85
- startError: string | undefined;
86
- /** The bar's state, or nothing when there is no upload to describe. */
87
- upload: UploadStatus | undefined;
88
- /** See {@link SubmissionActions} for why these are their own bag. */
89
- actions: SubmissionActions<S>;
46
+ /** What {@link useSubmissionState} hands back: the machine's view, and the actions. */
47
+ export type SubmissionState = WorkflowFormView & {
48
+ actions: SubmissionActions;
90
49
  };
91
- /** The shared submission scaffold. See the module doc. */
92
- export declare function useSubmissionState<S extends SubmissionToken>(): SubmissionState<S>;
50
+ /**
51
+ * The shared submission scaffold. See the module doc.
52
+ *
53
+ * @param recover - The mount-time lookup (`_recover-run.ts`), or nothing for a
54
+ * hook that refuses it (`useWorkflowStream`).
55
+ */
56
+ export declare function useSubmissionState(recover?: RecoverRunOptions): SubmissionState;
@@ -22,6 +22,7 @@
22
22
  * A clock only hangs up when `live` is `off`: a live conversation ends when
23
23
  * the caller says so, as the device's does.
24
24
  */
25
+ import { type ActorStore } from "./_actor-store.ts";
25
26
  import type { AgentState } from "./types.ts";
26
27
  /** What the session looks like to a tap: the three phases the hook publishes. */
27
28
  export type TapToTalkPhase = "idle" | "connecting" | "active";
@@ -78,11 +79,6 @@ export type TapToTalkView = {
78
79
  failed: boolean;
79
80
  };
80
81
  /** One running machine: send events, read and subscribe to its view, stop it. */
81
- export type TapToTalkStore = {
82
- send(event: TapToTalkEvent): void;
83
- getView(): TapToTalkView;
84
- subscribe(listener: () => void): () => void;
85
- stop(): void;
86
- };
82
+ export type TapToTalkStore = ActorStore<TapToTalkEvent, TapToTalkView>;
87
83
  /** Start a machine over `effects`. */
88
84
  export declare function createTapToTalk(effects: TapToTalkEffects, timing: () => TapToTalkTiming): TapToTalkStore;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * A workflow form's submission as a statechart, with no React: the mount-time
3
+ * lookup, the submission in flight, the person's pause, and what the page is
4
+ * left holding afterwards. `useWorkflowSubmit` and `useWorkflowStream` feed it
5
+ * through `_submission-state.ts`; `_workflow-form-state.test.ts` specs it
6
+ * without a renderer. The pattern is `_tap-to-talk-state.ts`'s and
7
+ * `session/audio-state.ts`'s: a `setup()` machine, effects in `input`,
8
+ * cancellable work as a `fromPromise` invoke.
9
+ *
10
+ * ```text
11
+ * idle ──RECOVER──► recovering ──found──► following
12
+ * ▲ │ └──none──► idle
13
+ * │ └──failed──► failed
14
+ * RESET (anywhere)
15
+ * SUBMIT (anywhere) ──► submitting { active ⇄ paused } ──done──► following
16
+ * └─failed─► failed
17
+ * ```
18
+ *
19
+ * It replaces four `useState`s, a `startedHere` flag, a `recovering` flag and
20
+ * a ref holding the live submission, and three rules that were each a patch
21
+ * over a race the position now answers:
22
+ *
23
+ * - **A superseded submission owns nothing.** It used to be dropped by a token
24
+ * compare in `end()` (`current !== token`). A `SUBMIT` now RE-ENTERS
25
+ * `submitting`, which stops the old invoke: its signal aborts, which cancels
26
+ * its gate, and XState drops its result. Reports from it are refused at the
27
+ * producer, on the same signal — the one "is this still wanted?" test, in the
28
+ * one place that knows the answer.
29
+ * - **The lookup never wins a race against a submit.** It used to be
30
+ * `setRunId(current => current ?? found)`. The lookup is an invoke of
31
+ * `recovering` now, so a `SUBMIT` (or a `RESET`) that leaves it stops it,
32
+ * and an answer that lands afterwards is an answer nobody asked for.
33
+ * - **`pending` was three hooks' flags OR-ed together.** Two of the three were
34
+ * positions in this machine; {@link WorkflowFormView.busy} is both. The
35
+ * third, the run's own `polling`, stays outside on purpose: it is
36
+ * `useWorkflowRun`'s and cannot be derived from the snapshot (see that hook).
37
+ *
38
+ * ## What `submitting` does not split
39
+ *
40
+ * Uploading and starting are not child states. The two hooks run them in
41
+ * OPPOSITE orders — `useWorkflowSubmit` stores the bytes and then starts the
42
+ * run, `useWorkflowStream` starts the run and then streams the bytes — so a
43
+ * fixed `uploading → starting` order would be wrong for one of them, and
44
+ * nothing this machine decides depends on which step is current. `active` /
45
+ * `paused` is the split that does matter: it is the gate, and the bar's
46
+ * `paused` flag, moving together.
47
+ */
48
+ import { type ActorStore } from "./_actor-store.ts";
49
+ import type { UploadGate } from "./upload/index.ts";
50
+ import type { UploadStatus } from "./use-workflow-form.ts";
51
+ /** What a running submission may report back, while it is still the live one. */
52
+ export type SubmissionReports = {
53
+ /** How far the bytes have got; `undefined` takes the bar down. */
54
+ progress: (status: UploadStatus | undefined) => void;
55
+ /** The run exists. `useWorkflowStream` says so before its bytes are in. */
56
+ started: (runId: string) => void;
57
+ };
58
+ /**
59
+ * One submission's body: the hook's own walk, with its ORDER.
60
+ *
61
+ * Resolving is success and rejecting is the submission's failure. A body that
62
+ * has been superseded or reset keeps running until it unwinds — its gate is
63
+ * cancelled, so it does so promptly — but nothing it reports or throws reaches
64
+ * the machine any more.
65
+ */
66
+ export type SubmissionWork = (reports: SubmissionReports) => Promise<void>;
67
+ /** What the machine decides and cannot do itself. */
68
+ export type WorkflowFormEffects = {
69
+ /** The newest run under the page's key, or `undefined` when it has none. */
70
+ find: () => Promise<string | undefined>;
71
+ };
72
+ /** Everything that moves the machine. */
73
+ export type WorkflowFormEvent =
74
+ /**
75
+ * Start a submission, superseding whatever is in flight. `settle` is called
76
+ * once, as the submission leaves `submitting` by any edge — which is what
77
+ * `submit()`'s promise resolves on.
78
+ */
79
+ {
80
+ type: "SUBMIT";
81
+ gate: UploadGate;
82
+ work: SubmissionWork;
83
+ settle: () => void;
84
+ }
85
+ /** Look up the page's run by key. Taken only while the page shows no run. */
86
+ | {
87
+ type: "RECOVER";
88
+ }
89
+ /** Put the form back: abandon the submission or the lookup, drop the result. */
90
+ | {
91
+ type: "RESET";
92
+ }
93
+ /** Park the bytes. */
94
+ | {
95
+ type: "PAUSE";
96
+ }
97
+ /** Send the rest. */
98
+ | {
99
+ type: "RESUME";
100
+ };
101
+ /** What the hooks render from. A new object only when a field changes. */
102
+ export type WorkflowFormView = {
103
+ /** The run on the page, once there is one. */
104
+ runId: string | undefined;
105
+ /** The submission's or the lookup's own failure, as against the run's. */
106
+ startError: string | undefined;
107
+ /** The bar's state, or nothing when there is no upload to describe. */
108
+ upload: UploadStatus | undefined;
109
+ /** Did a `SUBMIT` on this machine produce what is on the page? */
110
+ startedHere: boolean;
111
+ /** Looking up or submitting: the half of `pending` that is this machine's. */
112
+ busy: boolean;
113
+ };
114
+ /** One running machine: send events, read and subscribe to its view, stop it. */
115
+ export type WorkflowFormStore = ActorStore<WorkflowFormEvent, WorkflowFormView>;
116
+ /** Start a machine over `effects`. */
117
+ export declare function createWorkflowForm(effects: WorkflowFormEffects): WorkflowFormStore;
@@ -1 +1 @@
1
- import{a as e,i as t,t as n}from"./index-DdRGAZxZ.js";var r={echoCancellation:!0,noiseSuppression:!1,autoGainControl:!1,voiceIsolation:!1};function i(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}function a(e){let t=Error(`Audio ${e} worklet crashed`);return console.error(`[aai-ui]`,t.message),t}function o(e){e.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{})}function s(e,t,n){let r=new AudioWorkletNode(e,`capture-processor`,{channelCount:1,channelCountMode:`explicit`}),i=null;return r.port.onmessage=e=>{let r=e.data;r.event===`chunk`&&r.buffer?t(r.buffer):r.event===`silent`?n?.():r.event===`stopped`&&(i?.(),i=null)},{node:r,start(){r.port.postMessage({event:`start`})},stop(){let{promise:e,resolve:t}=Promise.withResolvers(),n=setTimeout(t,250);return i=()=>{clearTimeout(n),t()},r.port.postMessage({event:`stop`}),e}}}async function c(e){let{sampleRate:t,captureWorkletSrc:n,maxSeconds:a}=e,c=new AudioContext({sampleRate:t,latencyHint:`interactive`}),l=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),u;try{[u]=await Promise.all([l,c.resume(),c.audioWorklet.addModule(n)]),i(c.sampleRate,t,`capture`)}catch(e){throw o(l),await c.close().catch(()=>{}),e}let d=Math.round(t*a)*2,f=[],p=0,m=!1,h=null,g=c.createMediaStreamSource(u),_=s(c,e=>{if(h){h.onChunk(e);return}for(f.push(e),p+=e.byteLength;p>d&&f.length>1;)p-=f.shift()?.byteLength??0},()=>{h?h.onSilent?.():m=!0});g.connect(_.node),_.start();let v=()=>{let e=f;return f=[],p=0,e},y=!1,b=async()=>{y||(y=!0,g.disconnect(),_.node.disconnect(),await c.close().catch(e=>{console.warn(`AudioContext close failed:`,e)}))};return{sampleRate:t,stream:u,ctx:c,handOff(e){h=e;let t=v();return t.length>0&&e.onBuffered(t),m&&e.onSilent?.(),{mic:g,capture:_}},async detach(){return await _.stop(),await b(),{chunks:v(),sampleRate:t}},async close(){y||await _.stop();for(let e of u.getTracks())e.stop();await b()}}}function l(e){let t=new Int16Array(e.length);for(let n=0;n<e.length;n++){let r=Math.max(-1,Math.min(1,e[n]??0));t[n]=r<0?r*32768:r*32767}return t.buffer}async function u(e,t,r){let i=e.reduce((e,t)=>e+t.byteLength/2,0);if(i===0)return[];if(t===r)return e;try{let a=Math.round(i*r/t),o=new OfflineAudioContext(1,a,r),s=o.createBuffer(1,i,t),c=s.getChannelData(0),u=0;for(let t of e)for(let e of new Int16Array(t))c[u++]=e/32768;let d=o.createBufferSource();d.buffer=s,d.connect(o.destination),d.start();let f=(await o.startRendering()).getChannelData(0),p=Math.max(1,Math.round(r*n)),m=[];for(let e=0;e<f.length;e+=p)m.push(l(f.subarray(e,e+p)));return m}catch(e){return console.warn(`[aai-ui] pre-connect audio dropped: resample failed`,e),[]}}async function d(e,t,n){let r=e.createMediaStreamSource(t),i=s(e,n.onMicData,n.onMicSilent);if(r.connect(i.node),n.buffered){let{chunks:t,sampleRate:r}=n.buffered,i=await u(t,r,e.sampleRate);i.length>0&&n.onBuffered(i)}return i.start(),{mic:r,capture:i}}async function f(n){let{sttSampleRate:s,ttsSampleRate:c,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:f,onError:p,onPlaybackStats:m,onPlaybackProgress:h,onMicSilent:g,preConnect:_}=n,v=n.onPreConnectAudio??(e=>{for(let t of e)f(t)}),y=_?.sampleRate===s?_:null,b=_&&!y?await _.detach():null,x=new AudioContext({sampleRate:c,latencyHint:`playback`}),S=!y&&s===c,C=y?.ctx??(S?x:new AudioContext({sampleRate:s,latencyHint:`interactive`}));async function w(){let e=S?[x]:[x,C];await Promise.all(e.map(e=>e.close().catch(e=>{console.warn(`AudioContext close failed:`,e)})))}let T=_?Promise.resolve(_.stream):navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),E;try{[E]=await Promise.all([T,x.resume(),C.resume(),y?void 0:C.audioWorklet.addModule(l),x.audioWorklet.addModule(u)]),i(C.sampleRate,s,`capture`),i(x.sampleRate,c,`playback`)}catch(e){throw o(T),await w(),e}let{mic:D,capture:O}=y?y.handOff({onBuffered:v,onChunk:f,onSilent:g}):await d(C,E,{onMicData:f,onMicSilent:g,buffered:b,onBuffered:v});O.node.onprocessorerror=()=>p?.(a(`capture`));let k=null,A=0,j=null,M=new AbortController;function N(){let e=j;j=null,e?.settle()}function P(e){e.stats&&e.stats.concealedSamples>0&&m?.(e.stats),e.reason!==`interrupt`&&(j===null||e.turn===j.turn)&&N()}function F(){if(k)return k;let e=new AudioWorkletNode(x,`playback-processor`);return e.connect(x.destination),e.port.onmessage=e=>{e.data.event===`stop`?P(e.data):e.data.event===`progress`&&h?.(e.data.bufferedMs)},e.onprocessorerror=()=>{let e=a(`playback`);N(),p?.(e)},k=e,e}let I={enqueue(e){M.signal.aborted||e.byteLength!==0&&F().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){if(!k)return Promise.resolve();let n=++A;if(k.port.postMessage({event:`done`,turn:n}),x.state!==`running`)return N(),Promise.resolve();let{promise:r,resolve:i}=Promise.withResolvers();N();let a=()=>{clearInterval(o),clearTimeout(s),j?.settle===a&&(j=null),i()},o=setInterval(()=>{x.state!==`running`&&a()},e),s=setTimeout(a,t);return j={turn:n,settle:a},r},flush(){k&&(N(),k.port.postMessage({event:`interrupt`}))},async close(){if(!M.signal.aborted){M.abort(),await O.stop(),D.disconnect(),O.node.disconnect(),k&&k.disconnect();for(let e of E.getTracks())e.stop();await w()}},async[Symbol.asyncDispose](){await I.close()}};return I}export{f as createVoiceIO,c as openPreConnectCapture};
1
+ import{a as e,i as t,t as n}from"./index-Bov53GC9.js";var r={echoCancellation:!0,noiseSuppression:!1,autoGainControl:!1,voiceIsolation:!1};function i(e,t,n){if(e!==t)throw Error(`Browser refused the ${n} sample rate: asked for ${t} Hz, got ${e} Hz`)}function a(e){let t=Error(`Audio ${e} worklet crashed`);return console.error(`[aai-ui]`,t.message),t}function o(e){e.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{})}function s(e,t,n){let r=new AudioWorkletNode(e,`capture-processor`,{channelCount:1,channelCountMode:`explicit`}),i=null;return r.port.onmessage=e=>{let r=e.data;r.event===`chunk`&&r.buffer?t(r.buffer):r.event===`silent`?n?.():r.event===`stopped`&&(i?.(),i=null)},{node:r,start(){r.port.postMessage({event:`start`})},stop(){let{promise:e,resolve:t}=Promise.withResolvers(),n=setTimeout(t,250);return i=()=>{clearTimeout(n),t()},r.port.postMessage({event:`stop`}),e}}}async function c(e){let{sampleRate:t,captureWorkletSrc:n,maxSeconds:a}=e,c=new AudioContext({sampleRate:t,latencyHint:`interactive`}),l=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),u;try{[u]=await Promise.all([l,c.resume(),c.audioWorklet.addModule(n)]),i(c.sampleRate,t,`capture`)}catch(e){throw o(l),await c.close().catch(()=>{}),e}let d=Math.round(t*a)*2,f=[],p=0,m=!1,h=null,g=c.createMediaStreamSource(u),_=s(c,e=>{if(h){h.onChunk(e);return}for(f.push(e),p+=e.byteLength;p>d&&f.length>1;)p-=f.shift()?.byteLength??0},()=>{h?h.onSilent?.():m=!0});g.connect(_.node),_.start();let v=()=>{let e=f;return f=[],p=0,e},y=!1,b=async()=>{y||(y=!0,g.disconnect(),_.node.disconnect(),await c.close().catch(e=>{console.warn(`AudioContext close failed:`,e)}))};return{sampleRate:t,stream:u,ctx:c,handOff(e){h=e;let t=v();return t.length>0&&e.onBuffered(t),m&&e.onSilent?.(),{mic:g,capture:_}},async detach(){return await _.stop(),await b(),{chunks:v(),sampleRate:t}},async close(){y||await _.stop();for(let e of u.getTracks())e.stop();await b()}}}function l(e){let t=new Int16Array(e.length);for(let n=0;n<e.length;n++){let r=Math.max(-1,Math.min(1,e[n]??0));t[n]=r<0?r*32768:r*32767}return t.buffer}async function u(e,t,r){let i=e.reduce((e,t)=>e+t.byteLength/2,0);if(i===0)return[];if(t===r)return e;try{let a=Math.round(i*r/t),o=new OfflineAudioContext(1,a,r),s=o.createBuffer(1,i,t),c=s.getChannelData(0),u=0;for(let t of e)for(let e of new Int16Array(t))c[u++]=e/32768;let d=o.createBufferSource();d.buffer=s,d.connect(o.destination),d.start();let f=(await o.startRendering()).getChannelData(0),p=Math.max(1,Math.round(r*n)),m=[];for(let e=0;e<f.length;e+=p)m.push(l(f.subarray(e,e+p)));return m}catch(e){return console.warn(`[aai-ui] pre-connect audio dropped: resample failed`,e),[]}}async function d(e,t,n){let r=e.createMediaStreamSource(t),i=s(e,n.onMicData,n.onMicSilent);if(r.connect(i.node),n.buffered){let{chunks:t,sampleRate:r}=n.buffered,i=await u(t,r,e.sampleRate);i.length>0&&n.onBuffered(i)}return i.start(),{mic:r,capture:i}}async function f(n){let{sttSampleRate:s,ttsSampleRate:c,captureWorkletSrc:l,playbackWorkletSrc:u,onMicData:f,onError:p,onPlaybackStats:m,onPlaybackProgress:h,onMicSilent:g,preConnect:_}=n,v=n.onPreConnectAudio??(e=>{for(let t of e)f(t)}),y=_?.sampleRate===s?_:null,b=_&&!y?await _.detach():null,x=new AudioContext({sampleRate:c,latencyHint:`playback`}),S=!y&&s===c,C=y?.ctx??(S?x:new AudioContext({sampleRate:s,latencyHint:`interactive`}));async function w(){let e=S?[x]:[x,C];await Promise.all(e.map(e=>e.close().catch(e=>{console.warn(`AudioContext close failed:`,e)})))}let T=_?Promise.resolve(_.stream):navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},...r}}),E;try{[E]=await Promise.all([T,x.resume(),C.resume(),y?void 0:C.audioWorklet.addModule(l),x.audioWorklet.addModule(u)]),i(C.sampleRate,s,`capture`),i(x.sampleRate,c,`playback`)}catch(e){throw o(T),await w(),e}let{mic:D,capture:O}=y?y.handOff({onBuffered:v,onChunk:f,onSilent:g}):await d(C,E,{onMicData:f,onMicSilent:g,buffered:b,onBuffered:v});O.node.onprocessorerror=()=>p?.(a(`capture`));let k=null,A=0,j=null,M=new AbortController;function N(){let e=j;j=null,e?.settle()}function P(e){e.stats&&e.stats.concealedSamples>0&&m?.(e.stats),e.reason!==`interrupt`&&(j===null||e.turn===j.turn)&&N()}function F(){if(k)return k;let e=new AudioWorkletNode(x,`playback-processor`);return e.connect(x.destination),e.port.onmessage=e=>{e.data.event===`stop`?P(e.data):e.data.event===`progress`&&h?.(e.data.bufferedMs)},e.onprocessorerror=()=>{let e=a(`playback`);N(),p?.(e)},k=e,e}let I={enqueue(e){M.signal.aborted||e.byteLength!==0&&F().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){if(!k)return Promise.resolve();let n=++A;if(k.port.postMessage({event:`done`,turn:n}),x.state!==`running`)return N(),Promise.resolve();let{promise:r,resolve:i}=Promise.withResolvers();N();let a=()=>{clearInterval(o),clearTimeout(s),j?.settle===a&&(j=null),i()},o=setInterval(()=>{x.state!==`running`&&a()},e),s=setTimeout(a,t);return j={turn:n,settle:a},r},flush(){k&&(N(),k.port.postMessage({event:`interrupt`}))},async close(){if(!M.signal.aborted){M.abort(),await O.stop(),D.disconnect(),O.node.disconnect(),k&&k.disconnect();for(let e of E.getTracks())e.stop();await w()}},async[Symbol.asyncDispose](){await I.close()}};return I}export{f as createVoiceIO,c as openPreConnectCapture};
@@ -1,4 +1,4 @@
1
- import{n as e,t}from"./index-DdRGAZxZ.js";import{t as n}from"./_module-url-BX0RuRU2.js";var r=`
1
+ import{n as e,t}from"./index-Bov53GC9.js";import{t as n}from"./_module-url-BX0RuRU2.js";var r=`
2
2
  class CaptureProcessor extends AudioWorkletProcessor {
3
3
  constructor(options) {
4
4
  super();