@alexkroman1/aai-ui 1.9.2 → 1.11.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/dist/{_colors-DJUordGv.js → _colors-DYX7XRTr.js} +8 -0
- package/dist/{_utils-5cs73OrA.js → _utils-CN1yVgYS.js} +2 -5
- package/dist/audio.d.ts +6 -0
- package/dist/audio.js +62 -9
- package/dist/{chat-view-C1XbqDk0.js → chat-view-u3yBAlig.js} +16 -14
- package/dist/components/_colors.d.ts +0 -4
- package/dist/components/chat-view.js +1 -1
- package/dist/components/controls.d.ts +2 -2
- package/dist/components/controls.js +1 -1
- package/dist/components/message-list.d.ts +2 -2
- package/dist/components/message-list.js +70 -64
- package/dist/components/start-screen.js +1 -1
- package/dist/components/text-controls.d.ts +2 -2
- package/dist/components/tool-call-block.d.ts +6 -2
- package/dist/components/tool-call-block.js +1 -1
- package/dist/context.d.ts +3 -11
- package/dist/context.js +9 -22
- package/dist/{controls-BngrPbOC.js → controls-B2EPUJDU.js} +12 -9
- package/dist/default-client/assets/audio-CSwpo0gM.js +1 -0
- package/dist/default-client/assets/{capture-processor-C19oBn4L.js → capture-processor-UlKEKyIW.js} +4 -1
- package/dist/default-client/assets/index-B2bISVbm.css +2 -0
- package/dist/default-client/assets/index-BTqSueyz.js +94 -0
- package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js} +11 -4
- package/dist/default-client/index.html +2 -2
- package/dist/define-client.js +3 -3
- package/dist/hooks.js +5 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +354 -5
- package/dist/{session-core-D3NDaySY.js → session-core-KUoY-0JO.js} +251 -114
- package/dist/session-core-audio-setup.d.ts +36 -0
- package/dist/session-core-messages.d.ts +12 -0
- package/dist/session-core-reconnect.d.ts +19 -0
- package/dist/session-core-url.d.ts +2 -0
- package/dist/session-core.js +1 -1
- package/dist/sync-mic.d.ts +49 -0
- package/dist/sync-session.d.ts +49 -0
- package/dist/sync-vad.d.ts +54 -0
- package/dist/{tool-call-block-7f1GTG-P.js → tool-call-block-DIxpG8GM.js} +11 -6
- package/dist/types.d.ts +12 -14
- package/dist/types.js +1 -13
- package/dist/worklets/capture-processor.d.ts +1 -1
- package/dist/worklets/capture-processor.js +4 -1
- package/dist/worklets/playback-processor.d.ts +1 -1
- package/dist/worklets/playback-processor.js +11 -4
- package/package.json +8 -5
- package/dist/default-client/assets/audio-Cs-6t_Wd.js +0 -1
- package/dist/default-client/assets/index-Bf4ZTNcx.js +0 -73
- package/dist/default-client/assets/index-Bzlh9i7w.css +0 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync-mode browser session — HTTP turns, no WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* The client half of the server's `POST /sync` endpoint (see
|
|
5
|
+
* `host/sync-turn.ts` in `@alexkroman1/aai`): each turn is one request
|
|
6
|
+
* carrying committed text or one endpointed utterance of PCM16 audio plus
|
|
7
|
+
* the conversation history, answered with the transcript, the reply text,
|
|
8
|
+
* and (when the agent's TTS provider supports one-shot synthesis) the
|
|
9
|
+
* spoken reply. The server holds no session state — this object owns the
|
|
10
|
+
* history and replays it every turn.
|
|
11
|
+
*
|
|
12
|
+
* Microphone capture and utterance endpointing live in `sync-mic.ts` /
|
|
13
|
+
* `sync-vad.ts`; this module is transport only, so it also runs in
|
|
14
|
+
* non-browser clients that bring their own audio.
|
|
15
|
+
*/
|
|
16
|
+
import { type SyncHistoryMessage, type SyncTurnResponse } from "@alexkroman1/aai/protocol";
|
|
17
|
+
/** Base64-encode PCM16 samples (chunked — `btoa` takes a binary string). */
|
|
18
|
+
export declare function pcm16ToBase64(pcm: Int16Array): string;
|
|
19
|
+
/** Decode base64 PCM16LE (the sync response's `audio` field) into samples. */
|
|
20
|
+
export declare function base64ToPcm16(base64: string): Int16Array;
|
|
21
|
+
/** One completed sync turn: the wire response plus decoded reply audio. */
|
|
22
|
+
export type SyncTurnResult = SyncTurnResponse & {
|
|
23
|
+
/** Decoded reply audio (PCM16 at `sampleRate`), when the server spoke. */
|
|
24
|
+
pcm: Int16Array | null;
|
|
25
|
+
};
|
|
26
|
+
/** Configuration for {@link createSyncSession}. */
|
|
27
|
+
export type SyncSessionOptions = {
|
|
28
|
+
/** The agent server's sync endpoint, e.g. `http://localhost:3000/sync`. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** Fetch override (tests / custom transports). */
|
|
31
|
+
fetch?: typeof globalThis.fetch | undefined;
|
|
32
|
+
/** Called after every completed turn (text and voice alike). */
|
|
33
|
+
onTurn?: ((turn: SyncTurnResult) => void) | undefined;
|
|
34
|
+
/** Called when a turn fails; the rejection still propagates to the caller. */
|
|
35
|
+
onError?: ((err: Error) => void) | undefined;
|
|
36
|
+
};
|
|
37
|
+
/** Sync-mode session handle. */
|
|
38
|
+
export type SyncSession = {
|
|
39
|
+
/** Conversation so far, oldest first — replayed to the server each turn. */
|
|
40
|
+
readonly history: readonly SyncHistoryMessage[];
|
|
41
|
+
/** Run one turn from committed text. */
|
|
42
|
+
sendText(text: string): Promise<SyncTurnResult>;
|
|
43
|
+
/** Run one turn from one utterance of mono PCM16 audio. */
|
|
44
|
+
sendPcm16(pcm: Int16Array, sampleRate: number): Promise<SyncTurnResult>;
|
|
45
|
+
/** Forget the conversation. */
|
|
46
|
+
reset(): void;
|
|
47
|
+
};
|
|
48
|
+
/** Create a {@link SyncSession} against a sync-mode agent server. */
|
|
49
|
+
export declare function createSyncSession(opts: SyncSessionOptions): SyncSession;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side utterance detection (VAD) for sync mode.
|
|
3
|
+
*
|
|
4
|
+
* Sync mode has no streaming STT to endpoint speech server-side, so the
|
|
5
|
+
* browser decides where an utterance ends: this module is a pure
|
|
6
|
+
* energy-based voice-activity state machine fed PCM16 frames (from the
|
|
7
|
+
* WebRTC capture pipeline in `sync-mic.ts` — `getUserMedia`'s voice
|
|
8
|
+
* processing handles echo cancellation and noise suppression upstream,
|
|
9
|
+
* which is what makes a simple RMS gate workable). No browser APIs are
|
|
10
|
+
* touched here, so the state machine is fully unit-testable.
|
|
11
|
+
*
|
|
12
|
+
* Lifecycle per utterance: idle (keeping a pre-roll ring of recent audio)
|
|
13
|
+
* → candidate speech (voiced frames accumulating toward `minSpeechMs`)
|
|
14
|
+
* → speaking → `hangoverMs` of silence closes the utterance, which is
|
|
15
|
+
* returned as one PCM16 buffer including the pre-roll and the hangover
|
|
16
|
+
* tail (leading context and trailing silence both help one-shot STT).
|
|
17
|
+
*/
|
|
18
|
+
/** Tuning for {@link createUtteranceDetector}. */
|
|
19
|
+
export type UtteranceDetectorOptions = {
|
|
20
|
+
/** Sample rate of pushed PCM16 frames, in Hz. */
|
|
21
|
+
sampleRate: number;
|
|
22
|
+
/** RMS level (0..1 of full scale) a frame must exceed to count as voiced. */
|
|
23
|
+
speechRms?: number;
|
|
24
|
+
/** Sustained voiced audio required before an utterance starts. Filters clicks. */
|
|
25
|
+
minSpeechMs?: number;
|
|
26
|
+
/** Silence that ends an utterance once one has started. */
|
|
27
|
+
hangoverMs?: number;
|
|
28
|
+
/** Audio kept from before speech onset, so a soft first syllable survives. */
|
|
29
|
+
prerollMs?: number;
|
|
30
|
+
/** Hard cap: a monologue this long is emitted as an utterance mid-speech. */
|
|
31
|
+
maxUtteranceMs?: number;
|
|
32
|
+
};
|
|
33
|
+
export declare const DEFAULT_SPEECH_RMS = 0.015;
|
|
34
|
+
export declare const DEFAULT_MIN_SPEECH_MS = 150;
|
|
35
|
+
export declare const DEFAULT_HANGOVER_MS = 700;
|
|
36
|
+
export declare const DEFAULT_PREROLL_MS = 300;
|
|
37
|
+
export declare const DEFAULT_MAX_UTTERANCE_MS = 30000;
|
|
38
|
+
/** Utterance detector — feed PCM16 frames, get complete utterances back. */
|
|
39
|
+
export type UtteranceDetector = {
|
|
40
|
+
/**
|
|
41
|
+
* Push one capture frame (any length). Returns a complete utterance when
|
|
42
|
+
* this frame closed one, else null.
|
|
43
|
+
*/
|
|
44
|
+
push(frame: Int16Array): Int16Array | null;
|
|
45
|
+
/** End of stream: returns any in-progress utterance (candidate audio that
|
|
46
|
+
* never reached `minSpeechMs` is discarded as noise). */
|
|
47
|
+
flush(): Int16Array | null;
|
|
48
|
+
/** True while an utterance is being captured. */
|
|
49
|
+
readonly speaking: boolean;
|
|
50
|
+
/** Discard all buffered audio and return to idle. */
|
|
51
|
+
reset(): void;
|
|
52
|
+
};
|
|
53
|
+
/** Create an energy-based {@link UtteranceDetector}. */
|
|
54
|
+
export declare function createUtteranceDetector(opts: UtteranceDetectorOptions): UtteranceDetector;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { useTheme } from "./context.js";
|
|
2
|
-
import { r as TEXT_FAINT } from "./_colors-
|
|
2
|
+
import { r as TEXT_FAINT } from "./_colors-DYX7XRTr.js";
|
|
3
3
|
import { t as Eyebrow } from "./eyebrow-C6ZFuiz6.js";
|
|
4
|
-
import { n as tryParseJSON, t as truncate } from "./_utils-
|
|
4
|
+
import { n as tryParseJSON, t as truncate } from "./_utils-CN1yVgYS.js";
|
|
5
5
|
import clsx from "clsx";
|
|
6
|
-
import { createContext, useContext, useMemo, useState } from "react";
|
|
6
|
+
import { createContext, memo, useContext, useMemo, useState } from "react";
|
|
7
7
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
8
8
|
//#region components/tool-config-context.ts
|
|
9
9
|
/**
|
|
@@ -40,6 +40,10 @@ function formatResult(result) {
|
|
|
40
40
|
* While the tool call is pending a shimmer animation is shown. Once
|
|
41
41
|
* complete, clicking the row expands the formatted JSON result.
|
|
42
42
|
*
|
|
43
|
+
* Memoized: tool-call objects are referentially stable across session
|
|
44
|
+
* snapshots and rows are keyed on the stable `callId`, so a list update only
|
|
45
|
+
* re-renders the rows whose tool call actually changed.
|
|
46
|
+
*
|
|
43
47
|
* @example
|
|
44
48
|
* ```tsx
|
|
45
49
|
* <ToolCallBlock toolCall={toolCall} />
|
|
@@ -50,10 +54,11 @@ function formatResult(result) {
|
|
|
50
54
|
*
|
|
51
55
|
* @public
|
|
52
56
|
*/
|
|
53
|
-
function ToolCallBlock({ toolCall, className }) {
|
|
57
|
+
const ToolCallBlock = memo(function ToolCallBlock({ toolCall, className }) {
|
|
54
58
|
const [isOpen, setIsOpen] = useState(false);
|
|
55
59
|
const theme = useTheme();
|
|
56
|
-
const
|
|
60
|
+
const toolConfig = useToolConfig();
|
|
61
|
+
const config = Object.hasOwn(toolConfig, toolCall.name) ? toolConfig[toolCall.name] : void 0;
|
|
57
62
|
const isPending = toolCall.status === "pending";
|
|
58
63
|
const title = config?.label || toolCall.name;
|
|
59
64
|
const icon = config?.icon;
|
|
@@ -128,6 +133,6 @@ function ToolCallBlock({ toolCall, className }) {
|
|
|
128
133
|
})]
|
|
129
134
|
})]
|
|
130
135
|
});
|
|
131
|
-
}
|
|
136
|
+
});
|
|
132
137
|
//#endregion
|
|
133
138
|
export { ToolConfigContext as n, ToolCallBlock as t };
|
package/dist/types.d.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import type { SessionErrorCode } from "@alexkroman1/aai/protocol";
|
|
2
|
-
|
|
3
|
-
export declare const MIC_BUFFER_SECONDS = 0.1;
|
|
4
|
-
/**
|
|
5
|
-
* Backpressure threshold for outbound mic audio. When the WebSocket's
|
|
6
|
-
* `bufferedAmount` exceeds this many bytes (~2s of 16 kHz PCM16), mic frames
|
|
7
|
-
* are dropped instead of queued — for live voice, stale audio flushed into
|
|
8
|
-
* STT on recovery is worse than a gap.
|
|
9
|
-
*/
|
|
10
|
-
export declare const MIC_SEND_MAX_BUFFERED_BYTES: number;
|
|
11
|
-
/** Poll interval while waiting out socket backpressure during a file send. */
|
|
12
|
-
export declare const FILE_SEND_BACKOFF_MS = 50;
|
|
2
|
+
export { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES, } from "@alexkroman1/aai";
|
|
13
3
|
/**
|
|
14
4
|
* Current state of the voice agent session.
|
|
15
5
|
*
|
|
@@ -81,17 +71,25 @@ export type VoiceSessionOptions = {
|
|
|
81
71
|
* Called when the server sends a session ID in the config message.
|
|
82
72
|
* Use this to store the ID (e.g. in localStorage) for reconnection
|
|
83
73
|
* via `resumeSessionId`.
|
|
74
|
+
*
|
|
75
|
+
* Treat session IDs as sensitive: whoever holds one can resume the
|
|
76
|
+
* session and read its replayed history. They travel as a WebSocket
|
|
77
|
+
* query parameter (browsers cannot set WS headers), so they may appear
|
|
78
|
+
* in proxy and server access logs — don't put them in shared URLs.
|
|
84
79
|
*/
|
|
85
80
|
onSessionId?: ((sessionId: string) => void) | undefined;
|
|
86
81
|
/**
|
|
87
82
|
* Session ID from a previous connection. When set, the server will
|
|
88
83
|
* attempt to restore persisted session state (if the agent has
|
|
89
|
-
* `persistence` enabled).
|
|
84
|
+
* `persistence` enabled). Sensitive — see {@link onSessionId}.
|
|
90
85
|
*/
|
|
91
86
|
resumeSessionId?: string | undefined;
|
|
92
87
|
/**
|
|
93
|
-
* WebSocket constructor override.
|
|
94
|
-
*
|
|
88
|
+
* WebSocket constructor override. Primarily useful for testing with a mock
|
|
89
|
+
* WebSocket. When omitted, the session uses a reconnecting WebSocket
|
|
90
|
+
* (partysocket) that retries with exponential backoff after an unexpected
|
|
91
|
+
* close and resumes the session; an injected constructor is used as-is and
|
|
92
|
+
* never reconnects on its own.
|
|
95
93
|
*/
|
|
96
94
|
WebSocket?: WebSocketConstructor | undefined;
|
|
97
95
|
};
|
package/dist/types.js
CHANGED
|
@@ -1,14 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
/** Microphone buffer duration in seconds before sending to the server. */
|
|
3
|
-
const MIC_BUFFER_SECONDS = .1;
|
|
4
|
-
/**
|
|
5
|
-
* Backpressure threshold for outbound mic audio. When the WebSocket's
|
|
6
|
-
* `bufferedAmount` exceeds this many bytes (~2s of 16 kHz PCM16), mic frames
|
|
7
|
-
* are dropped instead of queued — for live voice, stale audio flushed into
|
|
8
|
-
* STT on recovery is worse than a gap.
|
|
9
|
-
*/
|
|
10
|
-
const MIC_SEND_MAX_BUFFERED_BYTES = 64 * 1024;
|
|
11
|
-
/** Poll interval while waiting out socket backpressure during a file send. */
|
|
12
|
-
const FILE_SEND_BACKOFF_MS = 50;
|
|
13
|
-
//#endregion
|
|
1
|
+
import { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES } from "@alexkroman1/aai";
|
|
14
2
|
export { FILE_SEND_BACKOFF_MS, MIC_BUFFER_SECONDS, MIC_SEND_MAX_BUFFERED_BYTES };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** Raw worklet source — exported so tests can evaluate the processor directly. */
|
|
2
|
-
export declare const captureProcessorSource = "\nclass CaptureProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.recording = false;\n const opts = options.processorOptions || {};\n this.fromRate = opts.contextRate || sampleRate;\n this.toRate = opts.sttSampleRate || sampleRate;\n this.ratio = this.fromRate / this.toRate;\n this.needsResample = this.fromRate !== this.toRate;\n // Streaming-resampler state carried across process() blocks so the output\n // clock doesn't drift and there's no discontinuity at 128-sample block\n // boundaries. `prev` is the previous block's last input sample (used to\n // interpolate across the boundary); `pos` is the fractional read position,\n // in input samples, relative to an extended [prev, ...input] frame. Start\n // at 1 so the first output is input[0] (not the bogus initial prev).\n this.prev = 0;\n this.pos = 1;\n // Output buffer reused across process() calls -- the render quantum is a\n // fixed size, so allocating per call would churn the realtime audio thread.\n this.resampleBuf = null;\n // Int16 accumulation buffer: flushed to the main thread as one transferred\n // ArrayBuffer once ~bufferSeconds of output samples are batched. Sized 2x\n // the flush target so a whole render quantum always fits before flushing.\n this.targetSamples = Math.max(1, Math.round(this.toRate * (opts.bufferSeconds || 0.1)));\n this.pending = new Int16Array(this.targetSamples * 2);\n this.pendingLen = 0;\n this.port.onmessage = (e) => {\n if (e.data.event === 'start') this.recording = true;\n else if (e.data.event === 'stop') {\n // Final flush so the tail of speech isn't dropped on close.\n this.flush();\n this.recording = false;\n }\n };\n }\n\n resample(input) {\n const ratio = this.ratio;\n const n = input.length;\n if (n === 0) return new Float32Array(0);\n // At most ceil(n / ratio) + 1 outputs per block (pos starts in [0, ratio)).\n const maxLen = Math.ceil(n / ratio) + 1;\n if (this.resampleBuf === null || this.resampleBuf.length < maxLen) {\n this.resampleBuf = new Float32Array(maxLen);\n }\n const out = this.resampleBuf;\n // Extended frame: index 0 = prev (previous block's last sample),\n // index k>=1 = input[k-1]. Interpolate at fractional positions stepping by\n // ratio, carrying `pos` across calls to preserve the sample clock.\n let count = 0;\n let pos = this.pos;\n while (pos < n) {\n const idx = pos | 0;\n const frac = pos - idx;\n const a = idx === 0 ? this.prev : input[idx - 1];\n const b = input[idx];\n out[count++] = a + frac * (b - a);\n pos += ratio;\n }\n // Shift the origin to this block's last sample for the next call.\n this.prev = input[n - 1];\n this.pos = pos - n;\n return out.subarray(0, count);\n }\n\n // Convert Float32 -> Int16 and append to the pending batch. Writes through\n // an Int16Array directly (assignment truncates like DataView.setInt16).\n accumulate(samples) {\n let buf = this.pending;\n if (this.pendingLen + samples.length > buf.length) {\n // Defensive: only reachable if a render quantum outproduces the 1x\n // headroom above the flush target (never with 128-sample quanta).\n const grown = new Int16Array((this.pendingLen + samples.length) * 2);\n grown.set(buf.subarray(0, this.pendingLen));\n this.pending = grown;\n buf = grown;\n }\n for (let i = 0; i < samples.length; i++) {\n const s = Math.max(-1, Math.min(1, samples[i]));\n buf[this.pendingLen++] = s < 0 ? s * 0x8000 : s * 0x7fff;\n }\n }\n\n // Post the batched samples as one transferred ArrayBuffer and reset.\n flush() {\n if (this.pendingLen === 0) return;\n const buffer = this.pending.buffer.slice(0, this.pendingLen * 2);\n this.pendingLen = 0;\n this.port.postMessage({ event: 'chunk', buffer }, [buffer]);\n }\n\n process(inputs) {\n const input = inputs[0];\n if (!input || !input[0] || !this.recording) return true;\n\n const samples = this.needsResample ? this.resample(input[0]) : input[0];\n this.accumulate(samples);\n if (this.pendingLen >= this.targetSamples) this.flush();\n return true;\n }\n}\n\nregisterProcessor('capture-processor', CaptureProcessor);\n";
|
|
2
|
+
export declare const captureProcessorSource = "\nclass CaptureProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.recording = false;\n const opts = options.processorOptions || {};\n this.fromRate = opts.contextRate || sampleRate;\n this.toRate = opts.sttSampleRate || sampleRate;\n this.ratio = this.fromRate / this.toRate;\n this.needsResample = this.fromRate !== this.toRate;\n // Streaming-resampler state carried across process() blocks so the output\n // clock doesn't drift and there's no discontinuity at 128-sample block\n // boundaries. `prev` is the previous block's last input sample (used to\n // interpolate across the boundary); `pos` is the fractional read position,\n // in input samples, relative to an extended [prev, ...input] frame. Start\n // at 1 so the first output is input[0] (not the bogus initial prev).\n this.prev = 0;\n this.pos = 1;\n // Output buffer reused across process() calls -- the render quantum is a\n // fixed size, so allocating per call would churn the realtime audio thread.\n this.resampleBuf = null;\n // Int16 accumulation buffer: flushed to the main thread as one transferred\n // ArrayBuffer once ~bufferSeconds of output samples are batched. Sized 2x\n // the flush target so a whole render quantum always fits before flushing.\n this.targetSamples = Math.max(1, Math.round(this.toRate * (opts.bufferSeconds || 0.1)));\n this.pending = new Int16Array(this.targetSamples * 2);\n this.pendingLen = 0;\n this.port.onmessage = (e) => {\n if (e.data.event === 'start') this.recording = true;\n else if (e.data.event === 'stop') {\n // Final flush so the tail of speech isn't dropped on close, then ack\n // so the host knows the tail chunk (if any) has been posted and it is\n // safe to tear the context down.\n this.flush();\n this.recording = false;\n this.port.postMessage({ event: 'stopped' });\n }\n };\n }\n\n resample(input) {\n const ratio = this.ratio;\n const n = input.length;\n if (n === 0) return new Float32Array(0);\n // At most ceil(n / ratio) + 1 outputs per block (pos starts in [0, ratio)).\n const maxLen = Math.ceil(n / ratio) + 1;\n if (this.resampleBuf === null || this.resampleBuf.length < maxLen) {\n this.resampleBuf = new Float32Array(maxLen);\n }\n const out = this.resampleBuf;\n // Extended frame: index 0 = prev (previous block's last sample),\n // index k>=1 = input[k-1]. Interpolate at fractional positions stepping by\n // ratio, carrying `pos` across calls to preserve the sample clock.\n let count = 0;\n let pos = this.pos;\n while (pos < n) {\n const idx = pos | 0;\n const frac = pos - idx;\n const a = idx === 0 ? this.prev : input[idx - 1];\n const b = input[idx];\n out[count++] = a + frac * (b - a);\n pos += ratio;\n }\n // Shift the origin to this block's last sample for the next call.\n this.prev = input[n - 1];\n this.pos = pos - n;\n return out.subarray(0, count);\n }\n\n // Convert Float32 -> Int16 and append to the pending batch. Writes through\n // an Int16Array directly (assignment truncates like DataView.setInt16).\n accumulate(samples) {\n let buf = this.pending;\n if (this.pendingLen + samples.length > buf.length) {\n // Defensive: only reachable if a render quantum outproduces the 1x\n // headroom above the flush target (never with 128-sample quanta).\n const grown = new Int16Array((this.pendingLen + samples.length) * 2);\n grown.set(buf.subarray(0, this.pendingLen));\n this.pending = grown;\n buf = grown;\n }\n for (let i = 0; i < samples.length; i++) {\n const s = Math.max(-1, Math.min(1, samples[i]));\n buf[this.pendingLen++] = s < 0 ? s * 0x8000 : s * 0x7fff;\n }\n }\n\n // Post the batched samples as one transferred ArrayBuffer and reset.\n flush() {\n if (this.pendingLen === 0) return;\n const buffer = this.pending.buffer.slice(0, this.pendingLen * 2);\n this.pendingLen = 0;\n this.port.postMessage({ event: 'chunk', buffer }, [buffer]);\n }\n\n process(inputs) {\n const input = inputs[0];\n if (!input || !input[0] || !this.recording) return true;\n\n const samples = this.needsResample ? this.resample(input[0]) : input[0];\n this.accumulate(samples);\n if (this.pendingLen >= this.targetSamples) this.flush();\n return true;\n }\n}\n\nregisterProcessor('capture-processor', CaptureProcessor);\n";
|
|
3
3
|
declare const src: string;
|
|
4
4
|
export default src;
|
|
@@ -29,9 +29,12 @@ class CaptureProcessor extends AudioWorkletProcessor {
|
|
|
29
29
|
this.port.onmessage = (e) => {
|
|
30
30
|
if (e.data.event === 'start') this.recording = true;
|
|
31
31
|
else if (e.data.event === 'stop') {
|
|
32
|
-
// Final flush so the tail of speech isn't dropped on close
|
|
32
|
+
// Final flush so the tail of speech isn't dropped on close, then ack
|
|
33
|
+
// so the host knows the tail chunk (if any) has been posted and it is
|
|
34
|
+
// safe to tear the context down.
|
|
33
35
|
this.flush();
|
|
34
36
|
this.recording = false;
|
|
37
|
+
this.port.postMessage({ event: 'stopped' });
|
|
35
38
|
}
|
|
36
39
|
};
|
|
37
40
|
}
|
|
@@ -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 rate = options.processorOptions?.sampleRate ?? 24000;\n // Wait for ~400ms of audio before starting.\n // If 'done' arrives first (short utterance), start immediately.\n this.jitterSamples = Math.floor(rate * 0.4);\n // Float32 ring buffer \u2014 60s at the context sample rate. Allocated once for\n // the node's lifetime; per-turn state resets via resetTurn(). writePos and\n // readPos are absolute (monotonic) sample counts; the buffer is indexed\n // modulo capacity so a reply longer than 60s keeps playing instead of\n // writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\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 this.interrupted = true;\n } else if (d.event === 'done') {\n this.isDone = true;\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.interrupted = false;\n this.isDone = false;\n this.playing = false;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\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 stopTurn() {\n this.port.postMessage({ event: 'stop' });\n this.resetTurn();\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 const out = outputs[0][0];\n if (this.interrupted) {\n out.fill(0);\n this.stopTurn();\n return true;\n }\n\n const avail = this.writePos - this.readPos;\n\n // Wait for jitter buffer to fill, unless done (short utterance)\n if (!this.playing) {\n if (avail >= this.jitterSamples || this.isDone) {\n this.playing = true;\n } else {\n out.fill(0);\n return true;\n }\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 out.fill(0, n);\n return true;\n }\n\n // No data: output silence, end the turn only when done\n out.fill(0);\n if (this.isDone) {\n this.stopTurn();\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 rate = options.processorOptions?.sampleRate ?? 24000;\n // Wait for ~400ms of audio before starting.\n // If 'done' arrives first (short utterance), start immediately.\n this.jitterSamples = Math.floor(rate * 0.4);\n // Float32 ring buffer \u2014 60s at the context sample rate. Allocated once for\n // the node's lifetime; per-turn state resets via resetTurn(). writePos and\n // readPos are absolute (monotonic) sample counts; the buffer is indexed\n // modulo capacity so a reply longer than 60s keeps playing instead of\n // writing past the end and going silent.\n this.capacity = rate * 60;\n this.samples = new Float32Array(this.capacity);\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 this.interrupted = true;\n } else if (d.event === 'done') {\n this.isDone = true;\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.interrupted = false;\n this.isDone = false;\n this.playing = false;\n // Carry-over byte for split samples across chunks\n this.carry = null;\n this.writePos = 0;\n this.readPos = 0;\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: interrupt-stops are dropped host-side (flush() already\n // settled that turn), so they can never resolve a later turn's done() early.\n stopTurn(reason) {\n this.port.postMessage({ event: 'stop', reason });\n this.resetTurn();\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 if (this.interrupted) {\n out.fill(0);\n this.stopTurn('interrupt');\n return true;\n }\n\n const avail = this.writePos - this.readPos;\n\n // Wait for jitter buffer to fill, unless done (short utterance)\n if (!this.playing) {\n if (avail >= this.jitterSamples || this.isDone) {\n this.playing = true;\n } else {\n out.fill(0);\n return true;\n }\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 out.fill(0, n);\n return true;\n }\n\n // No data: output silence, end the turn only when done\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 src: string;
|
|
4
4
|
export default src;
|
|
@@ -47,8 +47,11 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
47
47
|
// End the current turn: notify the host and rearm for the next reply.
|
|
48
48
|
// Must NOT return false from process() — a processor that stops is dead
|
|
49
49
|
// for good, forcing a new node (and buffer) per reply.
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// \`reason\` ('interrupt' | 'done') tells the host which turn boundary this
|
|
51
|
+
// stop belongs to: interrupt-stops are dropped host-side (flush() already
|
|
52
|
+
// settled that turn), so they can never resolve a later turn's done() early.
|
|
53
|
+
stopTurn(reason) {
|
|
54
|
+
this.port.postMessage({ event: 'stop', reason });
|
|
52
55
|
this.resetTurn();
|
|
53
56
|
}
|
|
54
57
|
|
|
@@ -106,10 +109,14 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
process(inputs, outputs) {
|
|
112
|
+
// No output wired up yet — nothing to render this quantum. Throwing here
|
|
113
|
+
// would permanently kill the processor (the node is persistent per
|
|
114
|
+
// session), so guard like the capture processor does.
|
|
115
|
+
if (!outputs[0] || !outputs[0][0]) return true;
|
|
109
116
|
const out = outputs[0][0];
|
|
110
117
|
if (this.interrupted) {
|
|
111
118
|
out.fill(0);
|
|
112
|
-
this.stopTurn();
|
|
119
|
+
this.stopTurn('interrupt');
|
|
113
120
|
return true;
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -140,7 +147,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
|
|
|
140
147
|
// No data: output silence, end the turn only when done
|
|
141
148
|
out.fill(0);
|
|
142
149
|
if (this.isDone) {
|
|
143
|
-
this.stopTurn();
|
|
150
|
+
this.stopTurn('done');
|
|
144
151
|
}
|
|
145
152
|
return true;
|
|
146
153
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alexkroman1/aai-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"clsx": "^2.1.1",
|
|
21
|
-
"
|
|
21
|
+
"partysocket": "^1.3.0",
|
|
22
|
+
"use-sync-external-store": "^1.6.0",
|
|
23
|
+
"@alexkroman1/aai": "1.11.0"
|
|
22
24
|
},
|
|
23
25
|
"peerDependencies": {
|
|
24
|
-
"react": "^19.
|
|
25
|
-
"react-dom": "^19.
|
|
26
|
-
"tailwindcss": "^4.
|
|
26
|
+
"react": "^19.0.0",
|
|
27
|
+
"react-dom": "^19.0.0",
|
|
28
|
+
"tailwindcss": "^4.0.0"
|
|
27
29
|
},
|
|
28
30
|
"peerDependenciesMeta": {
|
|
29
31
|
"tailwindcss": {
|
|
@@ -36,6 +38,7 @@
|
|
|
36
38
|
"@testing-library/react": "^16.3.2",
|
|
37
39
|
"@types/react": "^19.2.17",
|
|
38
40
|
"@types/react-dom": "^19.2.3",
|
|
41
|
+
"@types/use-sync-external-store": "^1.5.0",
|
|
39
42
|
"@vitejs/plugin-react": "^6.0.4",
|
|
40
43
|
"jsdom": "^29.1.1",
|
|
41
44
|
"react": "^19.2.8",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./index-Bf4ZTNcx.js";async function t(e,t){let n=await new OfflineAudioContext(1,1,t).decodeAudioData(e),r=Math.ceil(n.duration*t),i=new OfflineAudioContext(1,r,t),a=i.createBufferSource();a.buffer=n,a.connect(i.destination),a.start();let o=(await i.startRendering()).getChannelData(0),s=new Int16Array(o.length);for(let e=0;e<o.length;e++){let t=Math.max(-1,Math.min(1,o[e]??0));s[e]=t<0?t*32768:t*32767}return s}async function n(t){let{sttSampleRate:n,ttsSampleRate:r,captureWorkletSrc:i,playbackWorkletSrc:a,onMicData:o}=t,s=r,c=new AudioContext({sampleRate:s,latencyHint:`playback`}),l=navigator.mediaDevices.getUserMedia({audio:{deviceId:{ideal:`default`},echoCancellation:!0,noiseSuppression:!0,autoGainControl:!0,voiceIsolation:!0}}),u;try{[u]=await Promise.all([l,c.resume(),c.audioWorklet.addModule(i),c.audioWorklet.addModule(a)])}catch(e){throw l.then(e=>{for(let t of e.getTracks())t.stop()}).catch(()=>{}),await c.close().catch(e=>{console.warn(`AudioContext close failed:`,e)}),e}let d=c.createMediaStreamSource(u),f=new AudioWorkletNode(c,`capture-processor`,{channelCount:1,channelCountMode:`explicit`,processorOptions:{contextRate:s,sttSampleRate:n,bufferSeconds:e}});d.connect(f),f.port.postMessage({event:`start`}),f.port.onmessage=e=>{e.data.event===`chunk`&&o(e.data.buffer)};let p=null,m=null,h=new AbortController;function g(){if(p)return p;let e=new AudioWorkletNode(c,`playback-processor`,{processorOptions:{sampleRate:s}});return e.connect(c.destination),e.port.onmessage=e=>{e.data.event===`stop`&&(m?.(),m=null)},p=e,e}let _={enqueue(e){h.signal.aborted||e.byteLength!==0&&g().port.postMessage({event:`write`,buffer:new Uint8Array(e)},[e])},done(){return!p||c.state!==`running`?Promise.resolve():new Promise(e=>{m=e,p?.port.postMessage({event:`done`})})},flush(){p&&p.port.postMessage({event:`interrupt`})},async close(){if(!h.signal.aborted){h.abort(),f.port.postMessage({event:`stop`}),d.disconnect(),f.disconnect(),p&&p.disconnect();for(let e of u.getTracks())e.stop();await c.close().catch(()=>{})}},async[Symbol.asyncDispose](){await _.close()}};return _}export{n as createVoiceIO,t as decodeAudioToPcm16};
|