@memberjunction/ai-realtime-client 0.0.1 → 5.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +148 -28
  2. package/dist/audio/audioMeter.d.ts +101 -0
  3. package/dist/audio/audioMeter.d.ts.map +1 -0
  4. package/dist/audio/audioMeter.js +193 -0
  5. package/dist/audio/audioMeter.js.map +1 -0
  6. package/dist/audio/micCapture.d.ts +26 -0
  7. package/dist/audio/micCapture.d.ts.map +1 -0
  8. package/dist/audio/micCapture.js +69 -0
  9. package/dist/audio/micCapture.js.map +1 -0
  10. package/dist/audio/pcmPlayback.d.ts +73 -0
  11. package/dist/audio/pcmPlayback.d.ts.map +1 -0
  12. package/dist/audio/pcmPlayback.js +78 -0
  13. package/dist/audio/pcmPlayback.js.map +1 -0
  14. package/dist/audio/pcmUtils.d.ts +17 -0
  15. package/dist/audio/pcmUtils.d.ts.map +1 -0
  16. package/dist/audio/pcmUtils.js +46 -0
  17. package/dist/audio/pcmUtils.js.map +1 -0
  18. package/dist/drivers/assemblyAIRealtimeClient.d.ts +384 -0
  19. package/dist/drivers/assemblyAIRealtimeClient.d.ts.map +1 -0
  20. package/dist/drivers/assemblyAIRealtimeClient.js +732 -0
  21. package/dist/drivers/assemblyAIRealtimeClient.js.map +1 -0
  22. package/dist/drivers/elevenLabsRealtimeClient.d.ts +362 -0
  23. package/dist/drivers/elevenLabsRealtimeClient.d.ts.map +1 -0
  24. package/dist/drivers/elevenLabsRealtimeClient.js +686 -0
  25. package/dist/drivers/elevenLabsRealtimeClient.js.map +1 -0
  26. package/dist/drivers/geminiRealtimeClient.d.ts +406 -0
  27. package/dist/drivers/geminiRealtimeClient.d.ts.map +1 -0
  28. package/dist/drivers/geminiRealtimeClient.js +675 -0
  29. package/dist/drivers/geminiRealtimeClient.js.map +1 -0
  30. package/dist/drivers/openAIRealtimeClient.d.ts +381 -0
  31. package/dist/drivers/openAIRealtimeClient.d.ts.map +1 -0
  32. package/dist/drivers/openAIRealtimeClient.js +602 -0
  33. package/dist/drivers/openAIRealtimeClient.js.map +1 -0
  34. package/dist/drivers/xaiRealtimeClient.d.ts +430 -0
  35. package/dist/drivers/xaiRealtimeClient.d.ts.map +1 -0
  36. package/dist/drivers/xaiRealtimeClient.js +676 -0
  37. package/dist/drivers/xaiRealtimeClient.js.map +1 -0
  38. package/dist/generic/baseRealtimeClient.d.ts +382 -0
  39. package/dist/generic/baseRealtimeClient.d.ts.map +1 -0
  40. package/dist/generic/baseRealtimeClient.js +208 -0
  41. package/dist/generic/baseRealtimeClient.js.map +1 -0
  42. package/dist/index.d.ts +11 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +11 -0
  45. package/dist/index.js.map +1 -0
  46. package/package.json +28 -7
package/README.md CHANGED
@@ -1,45 +1,165 @@
1
1
  # @memberjunction/ai-realtime-client
2
2
 
3
- ## ⚠️ IMPORTANT NOTICE ⚠️
3
+ Framework-agnostic **browser-side** abstraction for provider-direct realtime (voice) sessions: the `BaseRealtimeClient` contract plus the four shipped provider drivers (`OpenAIRealtimeClient`, `GeminiRealtimeClient`, `ElevenLabsRealtimeClient`, `AssemblyAIRealtimeClient`) and the shared PCM audio plane (`src/audio/`) the websocket drivers build on.
4
4
 
5
- **This package is created solely for the purpose of setting up OIDC (OpenID Connect) trusted publishing with npm.**
5
+ This package is the **client-side mirror** of the server's `BaseRealtimeModel` pattern (`@memberjunction/ai`). In the **client-direct topology**, the MJ server mints an ephemeral credential + provider-native session config (`ClientRealtimeSessionConfig`) through its server driver, and the browser resolves the matching *client* driver through the MemberJunction `ClassFactory` using the config's `Provider` string as the registration key. The browser owns the provider socket (lowest audio latency — frames never transit the MJ server), while **prompt and tool authority stay server-side**: the client applies the server-built `SessionConfig` verbatim.
6
6
 
7
- This is **NOT** a functional package and contains **NO** code or functionality beyond the OIDC setup configuration.
7
+ For the full architecture — topologies, the co-agent model, channels, narration, security — see **[guides/REALTIME_CO_AGENTS_GUIDE.md](../../../guides/REALTIME_CO_AGENTS_GUIDE.md)**.
8
8
 
9
- ## Purpose
9
+ ## Installation
10
10
 
11
- This package exists to:
12
- 1. Configure OIDC trusted publishing for the package name `@memberjunction/ai-realtime-client`
13
- 2. Enable secure, token-less publishing from CI/CD workflows
14
- 3. Establish provenance for packages published under this name
11
+ ```bash
12
+ npm install @memberjunction/ai-realtime-client
13
+ ```
15
14
 
16
- ## What is OIDC Trusted Publishing?
15
+ Dependencies are intentionally tiny: `@memberjunction/global` (ClassFactory), `@memberjunction/ai` (the shared `ClientRealtimeSessionConfig` / `JSONObject` types), and `@google/genai` (the Gemini Live SDK). **No Angular, no DOM framework** — the package is plain TypeScript so it can be consumed by any browser host and unit-tested in plain Node.
17
16
 
18
- OIDC trusted publishing allows package maintainers to publish packages directly from their CI/CD workflows without needing to manage npm access tokens. Instead, it uses OpenID Connect to establish trust between the CI/CD provider (like GitHub Actions) and npm.
17
+ ## Architecture
19
18
 
20
- ## Setup Instructions
19
+ ```
20
+ MJ Server Browser
21
+ ───────── ───────
22
+ BaseRealtimeModel driver BaseRealtimeClient driver
23
+ .CreateClientSession() .Connect(config, micStream)
24
+ │ ▲
25
+ │ ClientRealtimeSessionConfig │ ClassFactory.CreateInstance(
26
+ │ { Provider, Model, │ BaseRealtimeClient,
27
+ │ EphemeralToken, ExpiresAt, ────────► │ config.Provider)
28
+ │ SessionConfig (opaque) } │ // 'openai' | 'gemini' | 'elevenlabs' | 'assemblyai'
29
+ ```
21
30
 
22
- To properly configure OIDC trusted publishing for this package:
31
+ **Division of responsibility** (from the `BaseRealtimeClient` doc header):
23
32
 
24
- 1. Go to [npmjs.com](https://www.npmjs.com/) and navigate to your package settings
25
- 2. Configure the trusted publisher (e.g., GitHub Actions)
26
- 3. Specify the repository and workflow that should be allowed to publish
27
- 4. Use the configured workflow to publish your actual package
33
+ - **Drivers own ALL provider wire concerns**: transport (WebRTC / WebSocket), event-name translation, the response state machine (a tool-result reply must never collide with an in-flight response), narration-kind tagging, and audible-playback tracking.
34
+ - **Hosts own POLICY**: when to narrate, what instructions to speak, transcript persistence, and UI state. The reference host is `RealtimeSessionService` in `@memberjunction/ng-conversations`.
28
35
 
29
- ## DO NOT USE THIS PACKAGE
36
+ ## The contract (`BaseRealtimeClient`)
30
37
 
31
- This package is a placeholder for OIDC configuration only. It:
32
- - Contains no executable code
33
- - Provides no functionality
34
- - Should not be installed as a dependency
35
- - Exists only for administrative purposes
38
+ | Member | Purpose |
39
+ |---|---|
40
+ | `Connect(config, micStream)` | Opens the provider connection with the server-minted ephemeral credential and applies `config.SessionConfig` **verbatim** once the control channel is ready. The *caller* acquires the mic (it owns the permission UX); the driver attaches it and stops its tracks on `Disconnect`. |
41
+ | `SendText(text)` | Injects typed text as a USER turn and asks for a reply through the same collision-safe path tool results use. **SendText implies barge-in**: an active spoken response is cancelled via `CancelActiveResponse` before the text is injected, so the typed turn takes the floor immediately. Must NOT synthesize a user-role transcript echo (the host owns the local echo). |
42
+ | `CancelActiveResponse()` | Cancels the model's ACTIVE spoken response and flushes pending playback so a new user turn can take the floor; no-op when nothing is active. A **floor-control** action only — it must never abort server-side delegated work (hosts do that from `OnInterruption` / their own policy). Leaves `IsBusy` / `IsAudioPlaying` honest afterward. |
43
+ | `SendContextNote(text)` | Injects background context (channel perception deltas, delegated-run progress) **without** forcing a spoken reply. |
44
+ | `RequestSpokenUpdate(instructions)` | Asks for ONE brief interim utterance; the resulting turn's transcripts MUST be tagged `Kind: 'narration'` and must never collide with a pending tool-result reply. |
45
+ | `SendToolResult(callID, outputJson)` | Feeds an executed tool's result back, ensuring the model speaks it ASAP — immediately when idle, otherwise queued behind the in-flight response so the trigger is never dropped. |
46
+ | `SetMuted(muted)` | Toggles mic tracks' `enabled` flag (transport stays up; the provider receives silence). |
47
+ | `Disconnect()` | Tears down everything; emits a final `'closed'` state; safe to call more than once. |
48
+ | `IsBusy` | `true` while a model response is in flight (generation). |
49
+ | `IsAudioPlaying` | `true` while audio is AUDIBLY playing. **Distinct from `IsBusy`** — generation runs ahead of playback; hosts must gate narration on BOTH or queued utterances come out stale. |
50
+ | `OnTranscript / OnToolCall / OnStateChange / OnError / OnInterruption / OnUsage` | Single-handler registration (matching the server `IRealtimeSession` style); registering again replaces the handler. `OnInterruption` fires on **true barge-in only** — user input cut off *active* model output (response in flight or audio audibly playing); a normal turn while the model is idle is not an interruption. Hosts use it per their own policy (the production host cancels pending narration; it deliberately does **not** abort delegated work — that's an explicit user action). |
51
+ | `OnUsage(handler)` | Token-usage telemetry as **deltas** for the response/turn that just completed (`RealtimeClientUsage` — cumulative-only providers must convert in the driver). **Optional capability**: providers without usage events simply never emit (registering is always safe). Emits: OpenAI (`response.done.usage`), Gemini (`usageMetadata`). Never emits: ElevenLabs, AssemblyAI (no wire usage events — ElevenLabs accounts platform-side; AssemblyAI bills flat per session-hour). The production host accumulates deltas and relays them debounced onto the co-agent `AIPromptRun` via the `RelayRealtimeUsage` mutation. |
36
52
 
37
- ## More Information
53
+ States (`RealtimeClientState`): `connecting → connected → listening ⇄ speaking → closed | error`. There is deliberately **no `thinking` state** — "the host is executing a tool" is host policy, not wire state.
38
54
 
39
- For more details about npm's trusted publishing feature, see:
40
- - [npm Trusted Publishing Documentation](https://docs.npmjs.com/generating-provenance-statements)
41
- - [GitHub Actions OIDC Documentation](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect)
55
+ Transcripts (`RealtimeClientTranscript`) carry `Role`, `Text` (interim events are incremental **deltas**, finals are the complete turn), `IsFinal`, and `Kind: 'normal' | 'narration'` — narration transcripts are ephemeral by product decision (never captions, never persisted).
42
56
 
43
- ---
57
+ Errors (`RealtimeClientError`): `Fatal: true` means the session is unusable (transport failure, credential expiry) and is also followed by an `'error'` state; `Fatal: false` is a recoverable provider error frame.
44
58
 
45
- **Maintained for OIDC setup purposes only**
59
+ ## Drivers
60
+
61
+ ### `OpenAIRealtimeClient` — `@RegisterClass(BaseRealtimeClient, 'openai')`
62
+
63
+ - **Transport**: WebRTC — mic tracks onto a peer connection, remote audio into a hidden `<audio>` sink, the `'oai-events'` data channel for control frames, and the GA SDP handshake. `SessionConfig` is applied via `session.update` when the data channel opens; `'listening'` is reported only after that (obligation #7).
64
+ - **Event translation**: GA *and* beta transcript event names, input-transcription completion, `response.function_call_arguments.done` tool calls, `input_audio_buffer.speech_started` barge-in, `output_audio_buffer.*` playback events, provider error frames.
65
+ - **Response state machine**: `responseActive` set on `response.created`, cleared on `response.done`; tool-result `response.create` triggers are queued while a response is in flight and flushed on `response.done` so the model **always** voices delegated results (obligation #5).
66
+ - **Narration tagging**: `RequestSpokenUpdate` marks the next response so its transcripts emit with `Kind: 'narration'`.
67
+ - **Playback tracking**: `IsAudioPlaying` from the WebRTC `output_audio_buffer` started/stopped events.
68
+
69
+ ### `GeminiRealtimeClient` — `@RegisterClass(BaseRealtimeClient, 'gemini')`
70
+
71
+ - **Transport**: WebSocket via the `@google/genai` Live SDK, authenticated with the server-minted ephemeral token (a `v1alpha` client).
72
+ - **Audio**: client → model is 16-bit PCM @ 16 kHz mono via the shared `createPcmMicCapture` worklet pipeline; model → client is PCM @ 24 kHz, scheduled gaplessly by `GeminiPcmPlayback` (a thin specialization of the shared `RealtimePcmPlayback`), which also backs `IsAudioPlaying` and flushes on barge-in (obligation #3).
73
+ - The server-built `SessionConfig` carries `{ model, config }` (system instruction, tools, transcription, modalities); the client applies it at `live.connect`.
74
+
75
+ ### `ElevenLabsRealtimeClient` — `@RegisterClass(BaseRealtimeClient, 'elevenlabs')`
76
+
77
+ - **Transport**: raw WebSocket against the server-minted **signed URL** — the `EphemeralToken` *is* the `wss://…&token=…` URL (no API key in the browser). Handshake: open → send `conversation_initiation_client_data` carrying the server-authored prompt override (from the `SessionConfig` pact `{ agentId, overrides, config }`) → wait for `conversation_initiation_metadata` → negotiate PCM rates from the metadata's audio-format tags → build the audio plane → `'listening'` (obligation #7). Non-PCM telephony formats (`ulaw_8000`) degrade loudly to the 16 kHz default with a warning.
78
+ - **Audio**: the shared PCM plane (`createPcmMicCapture` up as bare-key `user_audio_chunk` frames, `RealtimePcmPlayback` down from `audio` events) at the **negotiated** rates; `IsAudioPlaying` from the playout clock.
79
+ - **Capability deltas**: transcripts are **finals-only** (no interim deltas; `agent_response_correction` re-finalizes a barged-in turn with what was actually spoken — treat it as the authoritative replacement); `SendContextNote` is **native** (`contextual_update`, sent even mid-response); `RequestSpokenUpdate` is **emulated** as a `user_message` (queued behind in-flight responses; narration kind stamped at send time — there is no `response.created`-style frame to stamp on); there is **no cancel frame** — `CancelActiveResponse` flushes the locally-owned playout (residual server generation is simply never played); **no usage events**; `SendToolResult` is exactly-once (duplicate call ids dropped with a warning).
80
+ - **Busy mapping**: set on the first `audio` / `agent_response` of a turn, cleared on `agent_response_complete` / `interruption` / `client_tool_call` (obligation #2 — no envelope frames exist; state is inferred frame-by-frame).
81
+
82
+ ### `AssemblyAIRealtimeClient` — `@RegisterClass(BaseRealtimeClient, 'assemblyai')`
83
+
84
+ - **Transport**: raw WebSocket to `wss://agents.assemblyai.com/v1/ws?token=…` with the server-minted **one-time** temp token. Handshake: open → send the server-authored `session.update` (the whole session object: prompt, tools, voice, turn detection — from the `SessionConfig` pact `{ session, config }`) as the **first** frame → wait for `session.ready` → audio plane → `'listening'` (obligation #7; audio sent earlier would be dropped).
85
+ - **Audio**: the shared PCM plane at the provider's **fixed 24 kHz** format both directions (`input.audio` up, `reply.audio` down).
86
+ - **Capability deltas**: user transcripts stream as deltas + final, agent transcripts are **final-only** (a barged-in final carries the truncated text — no correction event); `RequestSpokenUpdate` is **native** (`reply.create` per-response instructions, queued behind in-flight replies); `SendText` is **emulated** via `reply.create` (the protocol has no typed-user-input event — best-effort fidelity); `SendContextNote` is emulated via the **mutable `system_prompt`** ("Background updates" section re-sent through `session.update` — a config write that never disturbs generation); **no cancel frame** — `CancelActiveResponse` flushes local playout *and suppresses* residual `reply.audio` of the cancelled reply until the next boundary; **no usage events** (flat session-hour billing).
87
+ - **Barge-in**: `input.speech.started` while output is active is the snappy flush point (~300 ms faster than waiting per the provider's guidance); `reply.done` `status: 'interrupted'` is the authoritative verdict / fallback flush. A speech start while idle is a normal turn, NOT an interruption.
88
+ - **Teardown**: `Disconnect()` sends `session.end` before closing — skipping it leaves a billable 30-second resume hold.
89
+
90
+ ### Shared audio plane (`src/audio/`)
91
+
92
+ The three websocket drivers (Gemini, ElevenLabs, AssemblyAI — everyone whose audio rides the socket rather than WebRTC) share one browser audio pipeline instead of reimplementing it per provider:
93
+
94
+ - **`createPcmMicCapture(micStream, sampleRate, onPcmChunk)`** (`micCapture.ts`) — `AudioWorklet`-based mic capture resampled to the requested rate, delivering base64 PCM16 chunks; the worklet is loaded from a Blob URL so the package ships no asset files.
95
+ - **`RealtimePcmPlayback`** (`pcmPlayback.ts`) — gapless playhead-clock scheduling of inbound PCM16, backing `IsAudioPlaying` precisely ("scheduled audio extends beyond the context's current time") with an instant `Flush()` for barge-in / cancel.
96
+ - **`pcmUtils.ts`** — base64 ↔ `ArrayBuffer` and PCM conversion helpers.
97
+
98
+ Drivers expose these through overridable `protected` creation seams (`createMicCapture` / `createPlayback`), so tests run with no audio hardware.
99
+
100
+ ## Driver-author obligations
101
+
102
+ `BaseRealtimeClient`'s doc header carries the authoritative client-side **"DRIVER AUTHOR OBLIGATIONS"** block (8 numbered rules, paid for in live debugging — the mirror of the server-side block on `BaseRealtimeModel` in `@memberjunction/ai`). The ones drivers trip over most: leave `'speaking'` *silently* (no state emission) when a tool call is emitted so the host's busy indicator isn't clobbered; release the busy flag at tool-call emission (deadlock guard); flush playback and report `IsAudioPlaying === false` promptly on barge-in *and* on `CancelActiveResponse`; never echo a user transcript for injected text; never drop a tool-result generation trigger (queue behind the in-flight response); surface credential expiry as a `Fatal` error; report `'listening'` only after the session config is applied; treat `SessionConfig` as a private pact between same-keyed driver halves. `RequestSpokenUpdate` has an explicit collision rule: when a response is already in flight the driver must queue or *skip* the update (skipping is fine — narration is disposable by contract); host-side `IsBusy`/`IsAudioPlaying` gating is for timing quality, the driver is the safety net.
103
+
104
+ ## Usage
105
+
106
+ ```typescript
107
+ import { MJGlobal } from '@memberjunction/global';
108
+ import {
109
+ BaseRealtimeClient,
110
+ LoadOpenAIRealtimeClient, LoadGeminiRealtimeClient,
111
+ LoadElevenLabsRealtimeClient, LoadAssemblyAIRealtimeClient
112
+ } from '@memberjunction/ai-realtime-client';
113
+
114
+ // Tree-shaking prevention — drivers are resolved dynamically, so a static call path
115
+ // must keep their @RegisterClass side effects alive:
116
+ LoadOpenAIRealtimeClient();
117
+ LoadGeminiRealtimeClient();
118
+ LoadElevenLabsRealtimeClient();
119
+ LoadAssemblyAIRealtimeClient();
120
+
121
+ // 1. The server minted a ClientRealtimeSessionConfig (e.g. via the
122
+ // StartRealtimeClientSession mutation). Resolve the matching driver:
123
+ const client = MJGlobal.Instance.ClassFactory.CreateInstance<BaseRealtimeClient>(
124
+ BaseRealtimeClient, startResult.Provider)!;
125
+
126
+ // 2. Wire policy handlers, then connect with the caller-acquired mic:
127
+ client.OnStateChange(state => updateUI(state));
128
+ client.OnTranscript(t => { if (t.IsFinal && t.Kind === 'normal') persistTurn(t); });
129
+ client.OnToolCall(async call => {
130
+ const resultJson = await executeTool(call.ToolName, call.ArgumentsJson);
131
+ client.SendToolResult(call.CallID, resultJson);
132
+ });
133
+ client.OnError(e => { if (e.Fatal) endSession(); });
134
+
135
+ const mic = await navigator.mediaDevices.getUserMedia({ audio: true });
136
+ await client.Connect(startResult.clientConfig, mic);
137
+
138
+ // 3. Later:
139
+ client.SendContextNote('[whiteboard] user added a sticky note: "Q3 goals"');
140
+ if (!client.IsBusy && !client.IsAudioPlaying) {
141
+ client.RequestSpokenUpdate('In one short first-person sentence, say the lookup is still running.');
142
+ }
143
+ await client.Disconnect();
144
+ ```
145
+
146
+ The production host is `RealtimeSessionService` (`packages/Angular/Generic/conversations`) — read it for the full policy layer (caption/transcript routing, prefix-routed client tools, narration pacing, channel plugins).
147
+
148
+ ## Testing seams
149
+
150
+ All four drivers are written against **structural transport seams** so the full event flow is unit-testable with zero network, zero WebRTC, and zero audio hardware (see `src/__tests__/`, ~4,200 lines of vitest coverage):
151
+
152
+ - **OpenAI**: `IRealtimePeerConnection`, `IRealtimeDataChannel`, `IRealtimeAudioSink` — created through overridable `protected` factory methods (`createPeerConnection`, `createAudioSink`, …); tests subclass the driver and inject fakes, then drive provider-shaped JSON frames through the data channel.
153
+ - **Gemini**: `GeminiLiveClientSession` (typed subset of the SDK `Session`), `IGeminiMicCapture`, `IGeminiAudioPlayback` — the `connectLiveSession` / capture / playback boundaries are the only things tests replace.
154
+ - **ElevenLabs / AssemblyAI**: `IElevenLabsClientSocket` / `IAssemblyAIClientSocket` (assignable-handler websocket seams behind `createSocket`) plus the shared `createMicCapture` / `createPlayback` seams — tests drive provider-shaped frames straight through the socket fake.
155
+ - Shared fakes live in `src/__tests__/helpers/realtime-fakes.ts`.
156
+
157
+ If you write a new driver, follow the same shape: every wire/hardware boundary behind a `protected` overridable seam, asserted with scripted provider frames.
158
+
159
+ ## Related
160
+
161
+ - [`guides/REALTIME_CO_AGENTS_GUIDE.md`](../../../guides/REALTIME_CO_AGENTS_GUIDE.md) — the flagship feature guide
162
+ - `@memberjunction/ai` — `BaseRealtimeModel`, `IRealtimeSession`, `ClientRealtimeSessionConfig`, `RealtimeToolDefinition`
163
+ - `@memberjunction/ai-agents` — `RealtimeSessionRunner`, `RealtimeToolBroker`, `RealtimeClientSessionService`
164
+ - `@memberjunction/ng-conversations` — the Angular host (overlay, channels, session review)
165
+ - `@memberjunction/ng-whiteboard` — the generic whiteboard the Whiteboard channel surfaces
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @fileoverview AUDIO ACTIVITY METERING for realtime clients — the Web Audio tap behind
3
+ * the call UI's audio-reactive visuals (the hero orb that "vibrates like a speaker cone"
4
+ * and the true-spectrum EQ bars).
5
+ *
6
+ * One meter wraps one `AnalyserNode` over either:
7
+ * - a `MediaStream` ({@link RealtimeAudioMeter.ForStream} — the mic everywhere; the remote
8
+ * WebRTC stream on OpenAI), or
9
+ * - a node inside an EXISTING audio graph ({@link RealtimeAudioMeter.ForContextNode} — the
10
+ * shared {@link RealtimePcmPlayback} master gain on the client-owned-audio drivers:
11
+ * Gemini Live, ElevenLabs Agents, AssemblyAI).
12
+ *
13
+ * Construction is DEFENSIVE by contract: in environments without Web Audio (unit tests,
14
+ * SSR) the factories return `null` and callers degrade to "no metering" — the call UI then
15
+ * keeps its turn-state-driven animations. The DSP math ({@link ComputeRmsLevel},
16
+ * {@link BucketizeFrequencyData}) is exported pure so it unit-tests without Web Audio.
17
+ */
18
+ /** The number of frequency bins the call UI's EQ renders (and meters therefore produce). */
19
+ export declare const REALTIME_AUDIO_BIN_COUNT = 9;
20
+ /**
21
+ * RMS level (0..1) of byte TIME-DOMAIN samples as `AnalyserNode.getByteTimeDomainData`
22
+ * delivers them: bytes centered on 128 (silence) spanning 0..255. Pure — unit-testable
23
+ * without Web Audio. Perceptual boost (×1.6, clamped) keeps normal speech visually alive
24
+ * without pinning shouts.
25
+ */
26
+ export declare function ComputeRmsLevel(timeDomainBytes: Uint8Array): number;
27
+ /**
28
+ * Averages byte FREQUENCY data (`AnalyserNode.getByteFrequencyData`, 0..255 per bin) into
29
+ * `count` equal buckets normalized 0..1. Only the lower ~70% of the spectrum is used —
30
+ * voice energy lives there; the top bins are mostly hiss and would flatten the display.
31
+ * Pure — unit-testable without Web Audio.
32
+ */
33
+ export declare function BucketizeFrequencyData(frequencyBytes: Uint8Array, count?: number): number[];
34
+ /**
35
+ * The level/spectrum surface one direction of audio exposes. Narrow interface so unit
36
+ * tests can substitute fakes for the Web Audio-backed {@link RealtimeAudioMeter}.
37
+ */
38
+ export interface IRealtimeAudioMeter {
39
+ /** Instantaneous RMS level, 0..1 (0 = silence). */
40
+ Level(): number;
41
+ /** The current spectrum as `count` normalized bins (default {@link REALTIME_AUDIO_BIN_COUNT}). */
42
+ Bins(count?: number): number[];
43
+ /** Releases the analyser (and the meter-owned `AudioContext`, when it created one). */
44
+ Close(): void;
45
+ }
46
+ /**
47
+ * `AnalyserNode`-backed audio meter. Use the static factories — they are defensive
48
+ * (return `null` where Web Audio is unavailable) and encode the two ownership modes:
49
+ * stream meters own a private `AudioContext`; graph meters tap a context the caller owns
50
+ * (and Close never closes it).
51
+ */
52
+ export declare class RealtimeAudioMeter implements IRealtimeAudioMeter {
53
+ private readonly analyser;
54
+ /** A context this meter created and therefore owns (closed in {@link Close}), or null. */
55
+ private readonly ownedContext;
56
+ /** Cloned tracks this meter owns (stopped in {@link Close}); empty when it taps tracks it doesn't own. */
57
+ private readonly ownedTracks;
58
+ private readonly timeDomain;
59
+ private readonly frequency;
60
+ private closed;
61
+ private constructor();
62
+ /**
63
+ * Shared builder: taps `stream` with a private `AudioContext` + analyser-only sink. A fresh
64
+ * `AudioContext` starts SUSPENDED and, with no destination route, nothing auto-starts its clock —
65
+ * so the analyser would read pure silence forever; we resume it (the session always starts from a
66
+ * user gesture, so this is permitted). `ownedTracks` are clones this meter must stop on Close.
67
+ */
68
+ private static buildStreamMeter;
69
+ /**
70
+ * Meters a `MediaStream` (e.g. a remote WebRTC stream) via a private `AudioContext`. Returns
71
+ * `null` when Web Audio / the stream isn't usable (tests, SSR, stopped tracks) — callers treat
72
+ * null as "no metering available". For the LOCAL microphone use {@link ForMicStream} instead.
73
+ */
74
+ static ForStream(stream: MediaStream): RealtimeAudioMeter | null;
75
+ /**
76
+ * Meters the LOCAL microphone. Identical to {@link ForStream} EXCEPT it taps a CLONE of the mic's
77
+ * audio track(s), not the track(s) themselves.
78
+ *
79
+ * Why the clone matters: the WebRTC realtime drivers add the mic track to an `RTCPeerConnection`
80
+ * (`pc.addTrack`). Once a local track feeds the WebRTC pipeline, Chromium reads pure SILENCE from a
81
+ * parallel `MediaStreamAudioSourceNode` over the SAME track — so the "Listening" meter never moves
82
+ * while the user speaks even though the mic is live. A cloned track is independent of the PC sender
83
+ * and meters reliably; it is stopped in {@link Close}. Harmless for the WS drivers (the clone is just
84
+ * an extra short-lived track). Returns `null` when Web Audio / the stream has no usable audio track.
85
+ */
86
+ static ForMicStream(stream: MediaStream): RealtimeAudioMeter | null;
87
+ /**
88
+ * Meters a node inside an EXISTING graph (e.g. {@link RealtimePcmPlayback}'s master
89
+ * gain). The caller keeps ownership of the context — {@link Close} only disconnects
90
+ * the analyser. Returns `null` when the analyser can't be created.
91
+ */
92
+ static ForContextNode(context: AudioContext, source: AudioNode): RealtimeAudioMeter | null;
93
+ private static createAnalyser;
94
+ /** @inheritdoc */
95
+ Level(): number;
96
+ /** @inheritdoc */
97
+ Bins(count?: number): number[];
98
+ /** @inheritdoc */
99
+ Close(): void;
100
+ }
101
+ //# sourceMappingURL=audioMeter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audioMeter.d.ts","sourceRoot":"","sources":["../../src/audio/audioMeter.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,4FAA4F;AAC5F,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAE1C;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,eAAe,EAAE,UAAU,GAAG,MAAM,CAWnE;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,cAAc,EAAE,UAAU,EAAE,KAAK,GAAE,MAAiC,GAAG,MAAM,EAAE,CAiBrH;AAED;;;GAGG;AACH,MAAM,WAAW,mBAAmB;IAChC,mDAAmD;IACnD,KAAK,IAAI,MAAM,CAAC;IAChB,kGAAkG;IAClG,IAAI,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC/B,uFAAuF;IACvF,KAAK,IAAI,IAAI,CAAC;CACjB;AAED;;;;;GAKG;AACH,qBAAa,kBAAmB,YAAW,mBAAmB;IAC1D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAe;IACxC,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAsB;IACnD,0GAA0G;IAC1G,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA0B;IACrD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;IACpD,OAAO,CAAC,MAAM,CAAS;IAEvB,OAAO;IAQP;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,gBAAgB;IAW/B;;;;OAIG;WACW,SAAS,CAAC,MAAM,EAAE,WAAW,GAAG,kBAAkB,GAAG,IAAI;IAQvE;;;;;;;;;;OAUG;WACW,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,kBAAkB,GAAG,IAAI;IAa1E;;;;OAIG;WACW,cAAc,CAAC,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,GAAG,kBAAkB,GAAG,IAAI;IAUjG,OAAO,CAAC,MAAM,CAAC,cAAc;IAO7B,kBAAkB;IACX,KAAK,IAAI,MAAM;IAQtB,kBAAkB;IACX,IAAI,CAAC,KAAK,GAAE,MAAiC,GAAG,MAAM,EAAE;IAQ/D,kBAAkB;IACX,KAAK,IAAI,IAAI;CAsBvB"}
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @fileoverview AUDIO ACTIVITY METERING for realtime clients — the Web Audio tap behind
3
+ * the call UI's audio-reactive visuals (the hero orb that "vibrates like a speaker cone"
4
+ * and the true-spectrum EQ bars).
5
+ *
6
+ * One meter wraps one `AnalyserNode` over either:
7
+ * - a `MediaStream` ({@link RealtimeAudioMeter.ForStream} — the mic everywhere; the remote
8
+ * WebRTC stream on OpenAI), or
9
+ * - a node inside an EXISTING audio graph ({@link RealtimeAudioMeter.ForContextNode} — the
10
+ * shared {@link RealtimePcmPlayback} master gain on the client-owned-audio drivers:
11
+ * Gemini Live, ElevenLabs Agents, AssemblyAI).
12
+ *
13
+ * Construction is DEFENSIVE by contract: in environments without Web Audio (unit tests,
14
+ * SSR) the factories return `null` and callers degrade to "no metering" — the call UI then
15
+ * keeps its turn-state-driven animations. The DSP math ({@link ComputeRmsLevel},
16
+ * {@link BucketizeFrequencyData}) is exported pure so it unit-tests without Web Audio.
17
+ */
18
+ /** The number of frequency bins the call UI's EQ renders (and meters therefore produce). */
19
+ export const REALTIME_AUDIO_BIN_COUNT = 9;
20
+ /**
21
+ * RMS level (0..1) of byte TIME-DOMAIN samples as `AnalyserNode.getByteTimeDomainData`
22
+ * delivers them: bytes centered on 128 (silence) spanning 0..255. Pure — unit-testable
23
+ * without Web Audio. Perceptual boost (×1.6, clamped) keeps normal speech visually alive
24
+ * without pinning shouts.
25
+ */
26
+ export function ComputeRmsLevel(timeDomainBytes) {
27
+ if (timeDomainBytes.length === 0) {
28
+ return 0;
29
+ }
30
+ let sumSquares = 0;
31
+ for (let i = 0; i < timeDomainBytes.length; i++) {
32
+ const centered = (timeDomainBytes[i] - 128) / 128;
33
+ sumSquares += centered * centered;
34
+ }
35
+ const rms = Math.sqrt(sumSquares / timeDomainBytes.length);
36
+ return Math.min(1, rms * 1.6);
37
+ }
38
+ /**
39
+ * Averages byte FREQUENCY data (`AnalyserNode.getByteFrequencyData`, 0..255 per bin) into
40
+ * `count` equal buckets normalized 0..1. Only the lower ~70% of the spectrum is used —
41
+ * voice energy lives there; the top bins are mostly hiss and would flatten the display.
42
+ * Pure — unit-testable without Web Audio.
43
+ */
44
+ export function BucketizeFrequencyData(frequencyBytes, count = REALTIME_AUDIO_BIN_COUNT) {
45
+ const bins = new Array(count).fill(0);
46
+ if (frequencyBytes.length === 0 || count <= 0) {
47
+ return bins;
48
+ }
49
+ const usable = Math.max(count, Math.floor(frequencyBytes.length * 0.7));
50
+ const perBucket = usable / count;
51
+ for (let b = 0; b < count; b++) {
52
+ const start = Math.floor(b * perBucket);
53
+ const end = Math.max(start + 1, Math.floor((b + 1) * perBucket));
54
+ let sum = 0;
55
+ for (let i = start; i < end && i < frequencyBytes.length; i++) {
56
+ sum += frequencyBytes[i];
57
+ }
58
+ bins[b] = Math.min(1, sum / ((end - start) * 255));
59
+ }
60
+ return bins;
61
+ }
62
+ /**
63
+ * `AnalyserNode`-backed audio meter. Use the static factories — they are defensive
64
+ * (return `null` where Web Audio is unavailable) and encode the two ownership modes:
65
+ * stream meters own a private `AudioContext`; graph meters tap a context the caller owns
66
+ * (and Close never closes it).
67
+ */
68
+ export class RealtimeAudioMeter {
69
+ constructor(analyser, ownedContext, ownedTracks = []) {
70
+ this.closed = false;
71
+ this.analyser = analyser;
72
+ this.ownedContext = ownedContext;
73
+ this.ownedTracks = ownedTracks;
74
+ this.timeDomain = new Uint8Array(analyser.fftSize);
75
+ this.frequency = new Uint8Array(analyser.frequencyBinCount);
76
+ }
77
+ /**
78
+ * Shared builder: taps `stream` with a private `AudioContext` + analyser-only sink. A fresh
79
+ * `AudioContext` starts SUSPENDED and, with no destination route, nothing auto-starts its clock —
80
+ * so the analyser would read pure silence forever; we resume it (the session always starts from a
81
+ * user gesture, so this is permitted). `ownedTracks` are clones this meter must stop on Close.
82
+ */
83
+ static buildStreamMeter(stream, ownedTracks) {
84
+ const context = new AudioContext();
85
+ const source = context.createMediaStreamSource(stream);
86
+ const analyser = RealtimeAudioMeter.createAnalyser(context);
87
+ source.connect(analyser);
88
+ if (context.state === 'suspended') {
89
+ void context.resume();
90
+ }
91
+ return new RealtimeAudioMeter(analyser, context, ownedTracks);
92
+ }
93
+ /**
94
+ * Meters a `MediaStream` (e.g. a remote WebRTC stream) via a private `AudioContext`. Returns
95
+ * `null` when Web Audio / the stream isn't usable (tests, SSR, stopped tracks) — callers treat
96
+ * null as "no metering available". For the LOCAL microphone use {@link ForMicStream} instead.
97
+ */
98
+ static ForStream(stream) {
99
+ try {
100
+ return RealtimeAudioMeter.buildStreamMeter(stream, []);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Meters the LOCAL microphone. Identical to {@link ForStream} EXCEPT it taps a CLONE of the mic's
108
+ * audio track(s), not the track(s) themselves.
109
+ *
110
+ * Why the clone matters: the WebRTC realtime drivers add the mic track to an `RTCPeerConnection`
111
+ * (`pc.addTrack`). Once a local track feeds the WebRTC pipeline, Chromium reads pure SILENCE from a
112
+ * parallel `MediaStreamAudioSourceNode` over the SAME track — so the "Listening" meter never moves
113
+ * while the user speaks even though the mic is live. A cloned track is independent of the PC sender
114
+ * and meters reliably; it is stopped in {@link Close}. Harmless for the WS drivers (the clone is just
115
+ * an extra short-lived track). Returns `null` when Web Audio / the stream has no usable audio track.
116
+ */
117
+ static ForMicStream(stream) {
118
+ try {
119
+ const audioTracks = stream.getAudioTracks();
120
+ if (audioTracks.length === 0) {
121
+ return null;
122
+ }
123
+ const clones = audioTracks.map((t) => t.clone());
124
+ return RealtimeAudioMeter.buildStreamMeter(new MediaStream(clones), clones);
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ /**
131
+ * Meters a node inside an EXISTING graph (e.g. {@link RealtimePcmPlayback}'s master
132
+ * gain). The caller keeps ownership of the context — {@link Close} only disconnects
133
+ * the analyser. Returns `null` when the analyser can't be created.
134
+ */
135
+ static ForContextNode(context, source) {
136
+ try {
137
+ const analyser = RealtimeAudioMeter.createAnalyser(context);
138
+ source.connect(analyser);
139
+ return new RealtimeAudioMeter(analyser, null);
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ static createAnalyser(context) {
146
+ const analyser = context.createAnalyser();
147
+ analyser.fftSize = 256; // 128 frequency bins — plenty for a 9-bar EQ, cheap to read
148
+ analyser.smoothingTimeConstant = 0.55;
149
+ return analyser;
150
+ }
151
+ /** @inheritdoc */
152
+ Level() {
153
+ if (this.closed) {
154
+ return 0;
155
+ }
156
+ this.analyser.getByteTimeDomainData(this.timeDomain);
157
+ return ComputeRmsLevel(this.timeDomain);
158
+ }
159
+ /** @inheritdoc */
160
+ Bins(count = REALTIME_AUDIO_BIN_COUNT) {
161
+ if (this.closed) {
162
+ return new Array(count).fill(0);
163
+ }
164
+ this.analyser.getByteFrequencyData(this.frequency);
165
+ return BucketizeFrequencyData(this.frequency, count);
166
+ }
167
+ /** @inheritdoc */
168
+ Close() {
169
+ if (this.closed) {
170
+ return;
171
+ }
172
+ this.closed = true;
173
+ try {
174
+ this.analyser.disconnect();
175
+ }
176
+ catch {
177
+ /* already disconnected */
178
+ }
179
+ // Stop any cloned tracks this meter owns (mic-meter clones) so they don't linger.
180
+ for (const track of this.ownedTracks) {
181
+ try {
182
+ track.stop();
183
+ }
184
+ catch {
185
+ /* already stopped */
186
+ }
187
+ }
188
+ if (this.ownedContext) {
189
+ void this.ownedContext.close();
190
+ }
191
+ }
192
+ }
193
+ //# sourceMappingURL=audioMeter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audioMeter.js","sourceRoot":"","sources":["../../src/audio/audioMeter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Handle returned by {@link createPcmMicCapture}: the only operation a driver needs is
3
+ * teardown. Production wraps an `AudioContext` + `AudioWorkletNode` pipeline; tests return a
4
+ * no-op fake.
5
+ */
6
+ export interface IPcmMicCapture {
7
+ /** Stops capture and releases the audio context / worklet resources. */
8
+ Stop(): void;
9
+ }
10
+ /**
11
+ * Builds the shared PCM16 mic-capture pipeline for client-owned realtime audio planes:
12
+ * an `AudioContext` at the requested sample rate, the inline-Blob capture worklet, and a
13
+ * zero-gain tail that keeps the graph pulled without audible monitoring. Each worklet block
14
+ * is PCM16-encoded and handed to `onPcmChunk` as base64.
15
+ *
16
+ * Extracted from the Gemini client driver (which captured fixed at 16 kHz) and
17
+ * sample-rate-parameterized so providers that negotiate the input format at session start
18
+ * (e.g. ElevenLabs' `user_input_audio_format`) capture at the negotiated rate.
19
+ *
20
+ * @param micStream The caller-acquired microphone stream (the caller owns the permission UX).
21
+ * @param sampleRate The PCM16 capture sample rate in Hz (16000 for Gemini Live and the
22
+ * ElevenLabs default).
23
+ * @param onPcmChunk Invoked with each captured block as base64-encoded PCM16.
24
+ */
25
+ export declare function createPcmMicCapture(micStream: MediaStream, sampleRate: number, onPcmChunk: (base64Pcm16: string) => void): Promise<IPcmMicCapture>;
26
+ //# sourceMappingURL=micCapture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"micCapture.d.ts","sourceRoot":"","sources":["../../src/audio/micCapture.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC3B,wEAAwE;IACxE,IAAI,IAAI,IAAI,CAAC;CAChB;AAkCD;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,mBAAmB,CACrC,SAAS,EAAE,WAAW,EACtB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,GAC1C,OAAO,CAAC,cAAc,CAAC,CAqBzB"}
@@ -0,0 +1,69 @@
1
+ import { encodeFloat32ToPcm16Base64 } from './pcmUtils.js';
2
+ /** Registration name for the inline mic-capture worklet processor. */
3
+ const CAPTURE_WORKLET_NAME = 'mj-realtime-pcm16-capture';
4
+ /**
5
+ * Inline AudioWorklet processor source (loaded via a Blob URL so the package ships no asset
6
+ * files). Runs inside the audio rendering thread: forwards each 128-frame mono input block to
7
+ * the main thread as a copied `Float32Array`. PCM16 conversion + base64 encoding happen on the
8
+ * main thread to keep the render-thread callback minimal.
9
+ */
10
+ const CAPTURE_WORKLET_SOURCE = `
11
+ class MJRealtimePcm16Capture extends AudioWorkletProcessor {
12
+ process(inputs) {
13
+ const channel = inputs[0] && inputs[0][0];
14
+ if (channel && channel.length > 0) {
15
+ this.port.postMessage(channel.slice(0));
16
+ }
17
+ return true;
18
+ }
19
+ }
20
+ registerProcessor('${CAPTURE_WORKLET_NAME}', MJRealtimePcm16Capture);
21
+ `;
22
+ /** Loads the inline capture worklet module from a Blob URL (no asset files shipped). */
23
+ async function loadCaptureWorklet(context) {
24
+ const blobUrl = URL.createObjectURL(new Blob([CAPTURE_WORKLET_SOURCE], { type: 'application/javascript' }));
25
+ try {
26
+ await context.audioWorklet.addModule(blobUrl);
27
+ }
28
+ finally {
29
+ URL.revokeObjectURL(blobUrl);
30
+ }
31
+ }
32
+ /**
33
+ * Builds the shared PCM16 mic-capture pipeline for client-owned realtime audio planes:
34
+ * an `AudioContext` at the requested sample rate, the inline-Blob capture worklet, and a
35
+ * zero-gain tail that keeps the graph pulled without audible monitoring. Each worklet block
36
+ * is PCM16-encoded and handed to `onPcmChunk` as base64.
37
+ *
38
+ * Extracted from the Gemini client driver (which captured fixed at 16 kHz) and
39
+ * sample-rate-parameterized so providers that negotiate the input format at session start
40
+ * (e.g. ElevenLabs' `user_input_audio_format`) capture at the negotiated rate.
41
+ *
42
+ * @param micStream The caller-acquired microphone stream (the caller owns the permission UX).
43
+ * @param sampleRate The PCM16 capture sample rate in Hz (16000 for Gemini Live and the
44
+ * ElevenLabs default).
45
+ * @param onPcmChunk Invoked with each captured block as base64-encoded PCM16.
46
+ */
47
+ export async function createPcmMicCapture(micStream, sampleRate, onPcmChunk) {
48
+ const context = new AudioContext({ sampleRate });
49
+ await loadCaptureWorklet(context);
50
+ const source = context.createMediaStreamSource(micStream);
51
+ const worklet = new AudioWorkletNode(context, CAPTURE_WORKLET_NAME);
52
+ worklet.port.onmessage = (event) => {
53
+ onPcmChunk(encodeFloat32ToPcm16Base64(event.data));
54
+ };
55
+ source.connect(worklet);
56
+ const muteTail = context.createGain();
57
+ muteTail.gain.value = 0;
58
+ worklet.connect(muteTail).connect(context.destination);
59
+ return {
60
+ Stop: () => {
61
+ worklet.port.onmessage = null;
62
+ source.disconnect();
63
+ worklet.disconnect();
64
+ muteTail.disconnect();
65
+ void context.close();
66
+ },
67
+ };
68
+ }
69
+ //# sourceMappingURL=micCapture.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"micCapture.js","sourceRoot":"","sources":["../../src/audio/micCapture.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAC;AAYxD,sEAAsE;AACtE,MAAM,oBAAoB,GAAG,2BAA2B,CAAC;AAEzD;;;;;GAKG;AACH,MAAM,sBAAsB,GAAG;;;;;;;;;;qBAUV,oBAAoB;CACxC,CAAC;AAEF,wFAAwF;AACxF,KAAK,UAAU,kBAAkB,CAAC,OAAqB;IACnD,MAAM,OAAO,GAAG,GAAG,CAAC,eAAe,CAAC,IAAI,IAAI,CAAC,CAAC,sBAAsB,CAAC,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE,CAAC,CAAC,CAAC;IAC5G,IAAI,CAAC;QACD,MAAM,OAAO,CAAC,YAAY,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;IAClD,CAAC;YAAS,CAAC;QACP,GAAG,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC;IACjC,CAAC;AACL,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACrC,SAAsB,EACtB,UAAkB,EAClB,UAAyC;IAEzC,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC,EAAE,UAAU,EAAE,CAAC,CAAC;IACjD,MAAM,kBAAkB,CAAC,OAAO,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,OAAO,CAAC,uBAAuB,CAAC,SAAS,CAAC,CAAC;IAC1D,MAAM,OAAO,GAAG,IAAI,gBAAgB,CAAC,OAAO,EAAE,oBAAoB,CAAC,CAAC;IACpE,OAAO,CAAC,IAAI,CAAC,SAAS,GAAG,CAAC,KAAiC,EAAE,EAAE;QAC3D,UAAU,CAAC,0BAA0B,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IACvD,CAAC,CAAC;IACF,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;IACxB,MAAM,QAAQ,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC;IACtC,QAAQ,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC;IACxB,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IACvD,OAAO;QACH,IAAI,EAAE,GAAG,EAAE;YACP,OAAO,CAAC,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC;YAC9B,MAAM,CAAC,UAAU,EAAE,CAAC;YACpB,OAAO,CAAC,UAAU,EAAE,CAAC;YACrB,QAAQ,CAAC,UAAU,EAAE,CAAC;YACtB,KAAK,OAAO,CAAC,KAAK,EAAE,CAAC;QACzB,CAAC;KACJ,CAAC;AACN,CAAC"}