diff --git a/Web/AmbientScribe/.env.example b/Web/AmbientScribe/.env.example new file mode 100644 index 0000000..e4835a7 --- /dev/null +++ b/Web/AmbientScribe/.env.example @@ -0,0 +1,4 @@ +CORTI_TENANT_NAME=your_tenant_name_here +CORTI_CLIENT_ID=your_client_id_here +CORTI_CLIENT_SECRET=your_client_secret_here +PORT=3000 diff --git a/Web/AmbientScribe/.gitignore b/Web/AmbientScribe/.gitignore new file mode 100644 index 0000000..94362eb --- /dev/null +++ b/Web/AmbientScribe/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store diff --git a/Web/AmbientScribe/README.md b/Web/AmbientScribe/README.md index 3423131..cac0b67 100644 --- a/Web/AmbientScribe/README.md +++ b/Web/AmbientScribe/README.md @@ -1,189 +1,237 @@ -# Corti AI Platform – Live Transcription & Fact-Based Documentation +# Corti AI Platform – Live Transcription & Fact-Based Documentation -This README provides a guide on using the **Corti AI Platform** WebSocket API for **live audio transcription** and **fact-based documentation**. It includes two approaches: -1. **Single audio stream** – Capturing audio from a single microphone. -2. **Dual-channel merged streams** – Combining a **local microphone** and a **WebRTC stream** for doctor-patient scenarios. +A single demo app using the [`@corti/sdk`](https://www.npmjs.com/package/@corti/sdk) for **live audio transcription**, **fact extraction**, and **clinical document generation**. Toggle between two modes from the UI: + +- **Single Microphone** – one audio source with automatic speaker diarization. +- **Virtual Consultation** – local microphone (doctor) + remote audio (patient) merged into a multi-channel stream. The remote audio can come from either a **WebRTC peer connection** or **screen/tab capture** (`getDisplayMedia`). + +After a consultation ends, generate a structured clinical document from the extracted facts with a single click. + +The demo is split into **server** (auth, interaction management, document generation) and **client** (audio capture, streaming, event display, document creation). --- -## **1. Overview of Configurations** +## Quick Start -### **Single Stream (Diarization Mode)** -This setup uses **one audio source** and **speaker diarization** to distinguish multiple speakers in the same channel automatically. +**Prerequisites:** Node.js 18+ -```ts -const DEFAULT_CONFIG: Config = { - type: "config", - configuration: { - transcription: { - primaryLanguage: "en", - isDiarization: true, // AI automatically differentiates speakers - isMultichannel: false, - participants: [ - { - channel: 0, - role: "multiple", - }, - ], - }, - mode: { type: "facts", outputLocale: "en" }, - }, -}; +**Setup (3 steps):** + +```bash +cp .env.example .env +# Edit .env with your Corti credentials (CORTI_TENANT_NAME, CORTI_CLIENT_ID, CORTI_CLIENT_SECRET) + +npm install +npm run dev ``` -### **Dual-Channel (Explicit Roles: Doctor & Patient)** -This setup **merges two separate audio streams** (e.g., a local microphone and a WebRTC stream). Instead of diarization, each stream is assigned a **fixed role** (Doctor or Patient). +Open http://localhost:3000 in your browser. Transcript and fact events appear in the browser console. -```ts -const DEFAULT_CONFIG: Config = { - type: "config", - configuration: { - transcription: { - primaryLanguage: "en", - isDiarization: false, // No automatic speaker detection - isMultichannel: false, - participants: [ - { channel: 0, role: "doctor" }, - { channel: 0, role: "patient" }, - ], - }, - mode: { type: "facts", outputLocale: "en" }, - }, -}; +--- + +## Installation (Manual) + +If setting up without npm: + +```bash +npm i @corti/sdk express +npm i -D typescript ts-node @types/express @types/node ``` + --- -## **2. Capturing Audio Streams** +## File Structure -### **Single Microphone Access** -Retrieves and returns a **MediaStream** from the user's microphone. -```ts -const microphoneStream = await getMicrophoneStream(); ``` +AmbientScribe/ + server.ts # Server-side: OAuth2 auth, interaction creation, scoped token, document generation + client.ts # Client-side: stream connection, audio capture, event handling, document creation + audio.ts # Audio utilities: getMicrophoneStream(), getRemoteParticipantStream(), getDisplayMediaStream(), mergeMediaStreams() + index.html # Minimal UI with mode toggle, consultation controls, and document output + README.md +``` + +--- + +## Server (`server.ts`) + +Runs on your backend. Responsible for: + +1. **Creating a `CortiClient`** with OAuth2 client credentials (never exposed to the browser). +2. **Creating an interaction** via the REST API. +3. **Minting a scoped stream token** (only grants WebSocket streaming access). +4. **Generating a clinical document** from the facts collected during a consultation. -### **Merging Two Streams (Microphone + WebRTC)** -For doctor-patient conversations, we merge two separate audio sources. ```ts -const { stream, endStream } = mergeMediaStreams([microphoneStream, webRTCStream]); +import { CortiClient, CortiAuth, CortiEnvironment } from "@corti/sdk"; + +// Full-privilege client — server-side only +const client = new CortiClient({ + environment: CortiEnvironment.Eu, + tenantName: "YOUR_TENANT_NAME", + auth: { clientId: "YOUR_CLIENT_ID", clientSecret: "YOUR_CLIENT_SECRET" }, +}); + +// Create an interaction +const interaction = await client.interactions.create({ + encounter: { identifier: randomUUID(), status: "planned", type: "first_consultation" }, +}); + +// Mint a token scoped to streaming only +const auth = new CortiAuth({ environment: CortiEnvironment.Eu, tenantName: "YOUR_TENANT_NAME" }); +const streamToken = await auth.getToken({ + clientId: "YOUR_CLIENT_ID", + clientSecret: "YOUR_CLIENT_SECRET", + scopes: ["stream"], +}); + +// Send interaction.id + streamToken.accessToken to the client ``` -**How Merging Works:** -- **Each stream is treated as a separate channel** -- **WebRTC provides the remote participant's audio** -- **The local microphone captures the speaker on-site** -- **The merged stream is sent to Corti’s API** +### Document Generation + +After a consultation ends, the server fetches the extracted facts and generates a structured clinical document: ```ts -export const mergeMediaStreams = (mediaStreams: MediaStream[]): { stream: MediaStream; endStream: () => void } => { - const audioContext = new AudioContext(); - const audioDestination = audioContext.createMediaStreamDestination(); - const channelMerger = audioContext.createChannelMerger(mediaStreams.length); - - mediaStreams.forEach((stream, index) => { - const source = audioContext.createMediaStreamSource(stream); - source.connect(channelMerger, 0, index); - }); - - channelMerger.connect(audioDestination); - - return { - stream: audioDestination.stream, - endStream: () => { - audioDestination.stream.getAudioTracks().forEach((track) => track.stop()); - audioContext.close(); - } - }; -}; +// 1. Fetch facts collected during the consultation +const facts = await client.facts.list(interactionId); + +// 2. Create a document from the facts +const document = await client.documents.create(interactionId, { + context: [ + { + type: "facts", + data: facts.map((fact) => ({ + text: fact.text, + group: fact.group, + source: fact.source, + })), + }, + ], + template: { + sections: [ + { key: "corti-hpi" }, + { key: "corti-allergies" }, + { key: "corti-social-history" }, + { key: "corti-plan" }, + ], + }, + outputLanguage: "en", + name: "Consultation Document", + documentationMode: "routed_parallel", +}); ``` --- -## **3. Establishing WebSocket Connection** -Once the audio stream is ready, we establish a WebSocket connection to Corti’s API. +## Audio Utilities (`audio.ts`) + +Three methods for obtaining audio streams, plus a merge utility: -### **Starting the Audio Flow** ```ts -const { stop } = await startAudioFlow(stream, authCreds, interactionId, handleNewMessage); +// 1. Local microphone +const micStream = await getMicrophoneStream(); + +// 2a. Remote participant from a WebRTC peer connection +const remoteStream = getRemoteParticipantStream(peerConnection); + +// 2b. OR: screen / tab capture (alternative when you don't control the peer connection, +// e.g. the video-call app runs in another browser tab) +const remoteStream = await getDisplayMediaStream(); + +// 3. Merge into a single multi-channel stream (virtual consultation mode) +const { stream, endStream } = mergeMediaStreams([micStream, remoteStream]); ``` -- **Sends real-time audio** -- **Receives transcription and facts** -- **Automatically starts when a CONFIG_ACCEPTED message is received** --- -## **4. Handling WebSocket Events (Transcripts & Facts)** -Each incoming WebSocket message is parsed and stored. +## Client (`client.ts`) + +Receives the scoped token + interaction ID from the server, then: + +1. Creates a `CortiClient` with the stream-scoped token. +2. Connects via `client.stream.connect()`. +3. Acquires audio — just the mic in single mode, or mic + remote merged in virtual mode. +4. Streams audio in 200 ms chunks via `MediaRecorder`. +5. Logs transcript and fact events to the console. ```ts -const transcripts: TranscriptEventData[] = []; -const facts: FactEventData[] = []; - -const handleNewMessage = (msg: MessageEvent) => { - const parsed = JSON.parse(msg.data); - if (parsed.type === "transcript") { - transcripts.push(parsed.data as TranscriptEventData); - } else if (parsed.type === "fact") { - facts.push(parsed.data as FactEventData); - } -}; -``` +const client = new CortiClient({ + environment: CortiEnvironment.Eu, + tenantName: "YOUR_TENANT_NAME", + auth: { accessToken }, // stream scope only +}); ---- +const streamSocket = await client.stream.connect({ id: interactionId }); + +// With a stream-scoped token, only streaming works: +// await client.interactions.list(); // Error — outside scope +// await client.transcribe.connect(); // Error — outside scope +``` -## **5. Stopping & Cleanup** -Ensure all resources (WebSocket, MediaRecorder, and merged streams) are properly closed. +### Single Microphone Mode ```ts -stop(); -microphoneStream.getAudioTracks().forEach((track) => track.stop()); -webRTCStream.getAudioTracks().forEach((track) => track.stop()); -endStream(); // Stops the merged audio -console.log("Call ended and resources cleaned up."); +const microphoneStream = await getMicrophoneStream(); +const mediaRecorder = new MediaRecorder(microphoneStream); +mediaRecorder.ondataavailable = (e) => streamSocket.send(e.data); +mediaRecorder.start(200); ``` ---- +### Virtual Consultation Mode + +The remote audio source is selected from the UI — either a WebRTC peer connection or screen/tab capture: -## **6. Full Flow Example** -### **Single-Stream (Diarization Mode)** ```ts -async function startSingleStreamCall() { - const microphoneStream = await getMicrophoneStream(); - const { stop } = await startAudioFlow(microphoneStream, authCreds, interactionId, handleNewMessage); - - return { - endCall: () => { - stop(); - microphoneStream.getAudioTracks().forEach((track) => track.stop()); - }, - }; -} +const microphoneStream = await getMicrophoneStream(); + +// Option A: WebRTC +const remoteStream = getRemoteParticipantStream(peerConnection); + +// Option B: Screen / tab capture (getDisplayMedia) +const remoteStream = await getDisplayMediaStream(); + +// channel 0 = doctor, channel 1 = patient +const { stream, endStream } = mergeMediaStreams([microphoneStream, remoteStream]); + +const mediaRecorder = new MediaRecorder(stream); +mediaRecorder.ondataavailable = (e) => streamSocket.send(e.data); +mediaRecorder.start(200); ``` -### **Dual-Channel (Doctor-Patient Setup)** +### Event Handling + ```ts -async function startDualChannelCall() { - const microphoneStream = await getMicrophoneStream(); - const webRTCStream = new MediaStream(); // Example WebRTC stream - - const { stream, endStream } = mergeMediaStreams([microphoneStream, webRTCStream]); - const { stop } = await startAudioFlow(stream, authCreds, interactionId, handleNewMessage); - - return { - endCall: () => { - stop(); - endStream(); - microphoneStream.getAudioTracks().forEach((track) => track.stop()); - webRTCStream.getAudioTracks().forEach((track) => track.stop()); - }, - }; -} +streamSocket.on("transcript", (data) => console.log("Transcript:", data)); +streamSocket.on("fact", (data) => console.log("Fact:", data)); +``` + +--- + +## UI (`index.html`) + +A minimal page with: + +- Radio buttons to toggle between **Single Microphone** and **Virtual Consultation** mode. +- When **Virtual Consultation** is selected, a second radio group appears to choose between **WebRTC** and **Screen / tab capture** as the remote audio source. +- **Start Consultation** / **End Consultation** buttons to control the streaming session. +- **Create Document** button — enabled after a consultation ends. Calls the server to fetch facts and generate a clinical document, then displays the result on the page. +- Transcript and fact events are logged to the browser console. + +--- + +## Production Build + +For production deployment, compile and run the server: + +```bash +npm run build # Compile TypeScript to dist/ +npm start # Run compiled server ``` --- -## **7. Summary** -🚀 **Two streaming options** – single microphone **(diarization)** or **merged dual-channel streams** (doctor-patient). -✅ **Minimal setup** – simply plug in credentials and select a mode. -📡 **Real-time AI transcription & fact extraction** – powered by **Corti’s API**. +## Resources -For further details, refer to **Corti's API documentation**. \ No newline at end of file +- [`@corti/sdk` on npm](https://www.npmjs.com/package/@corti/sdk) +- [Corti API documentation](https://docs.corti.ai) diff --git a/Web/AmbientScribe/audio.ts b/Web/AmbientScribe/audio.ts new file mode 100644 index 0000000..44163e3 --- /dev/null +++ b/Web/AmbientScribe/audio.ts @@ -0,0 +1,177 @@ +/** + * audio.ts — Audio stream utilities for AmbientScribe. + * + * Exposes three methods for obtaining audio streams: + * 1. getMicrophoneStream() — local microphone (works in both modes) + * 2. getRemoteParticipantStream() — remote party via WebRTC (virtual consultations) + * 3. getDisplayMediaStream() — screen/tab/window audio via getDisplayMedia + * (alternative to WebRTC for virtual consultations, + * e.g. capturing audio from a video-call app) + * + * Also provides mergeMediaStreams() for combining multiple streams into a + * single multi-channel stream before sending to Corti. + */ + +// --------------------------------------------------------------------------- +// 1. Local microphone +// --------------------------------------------------------------------------- + +/** + * Opens the user's microphone and returns the resulting MediaStream. + * + * @param deviceId Optional device ID if a specific microphone is desired. + * When omitted the browser's default audio input is used. + * @returns A MediaStream containing a single audio track from the microphone. + */ +export async function getMicrophoneStream( + deviceId?: string +): Promise { + if (!navigator.mediaDevices) { + throw new Error("Media Devices API not supported in this browser"); + } + + return navigator.mediaDevices.getUserMedia({ + audio: deviceId ? { deviceId: { exact: deviceId } } : true, + }); +} + +// --------------------------------------------------------------------------- +// 2. Remote participant (WebRTC) +// --------------------------------------------------------------------------- + +/** + * Extracts the remote participant's audio from an active WebRTC peer connection. + * + * In a virtual consultation the remote party's audio arrives via WebRTC. + * This helper collects all incoming audio tracks from the connection's + * receivers into a single MediaStream. + * + * @param peerConnection An RTCPeerConnection that already has remote audio tracks. + * @returns A MediaStream containing the remote participant's audio track(s). + * @throws If the peer connection has no remote audio tracks. + */ +export function getRemoteParticipantStream( + peerConnection: RTCPeerConnection +): MediaStream { + const remoteStream = new MediaStream(); + + for (const receiver of peerConnection.getReceivers()) { + if (receiver.track.kind === "audio") { + remoteStream.addTrack(receiver.track); + } + } + + if (!remoteStream.getAudioTracks().length) { + throw new Error("No remote audio tracks found on the peer connection"); + } + + return remoteStream; +} + +// --------------------------------------------------------------------------- +// 3. Screen / tab audio capture (getDisplayMedia) +// --------------------------------------------------------------------------- + +/** + * Captures audio from a screen, window, or browser tab using getDisplayMedia. + * + * This is an alternative to getRemoteParticipantStream() for virtual + * consultations where the remote party's audio comes through a video-call + * app running in another tab or window rather than a direct WebRTC + * peer connection you control. + * + * The browser will show a picker dialog asking which screen/tab to share. + * We request both audio and video (some browsers require video to be + * requested for tab audio to work) and then strip the video track so only + * the audio track remains. + * + * @returns A MediaStream containing only the audio track from the selected + * screen / tab / window. + * @throws If the browser doesn't support getDisplayMedia, the user cancels + * the picker, or the selected source has no audio track. + */ +export async function getDisplayMediaStream(): Promise { + if (!navigator.mediaDevices?.getDisplayMedia) { + throw new Error("getDisplayMedia is not supported in this browser"); + } + + // Request both audio and video — some browsers (e.g. Chrome) only expose + // tab audio when video is also requested. + const stream = await navigator.mediaDevices.getDisplayMedia({ + audio: true, + video: true, + }); + + // Remove all video tracks — we only need the audio. + for (const track of stream.getTracks()) { + if (track.kind === "video") { + track.stop(); + stream.removeTrack(track); + } + } + + if (!stream.getAudioTracks().length) { + throw new Error( + "The selected source does not have an audio track. " + + "Make sure to pick a browser tab that is playing audio." + ); + } + + return stream; +} + +// --------------------------------------------------------------------------- +// 4. Stream merging (used in virtual consultation mode) +// --------------------------------------------------------------------------- + +/** + * Merges multiple MediaStreams into a single multi-channel MediaStream. + * + * Each input stream is mapped to its own channel (by array index), so + * channel 0 = first stream, channel 1 = second stream, etc. + * This lets Corti attribute speech to the correct participant without + * relying on diarization. + * + * @param mediaStreams Array of MediaStreams to merge. Each must have at + * least one audio track. + * @returns An object with: + * - `stream` — the merged MediaStream to feed into MediaRecorder + * - `endStream` — cleanup function that stops tracks and closes the AudioContext + */ +export function mergeMediaStreams( + mediaStreams: MediaStream[] +): { stream: MediaStream; endStream: () => void } { + if (!mediaStreams.length) { + throw new Error("No media streams provided"); + } + + // Validate every stream has audio before we start wiring things up. + mediaStreams.forEach((stream, index) => { + if (!stream.getAudioTracks().length) { + throw new Error( + `MediaStream at index ${index} does not have an audio track` + ); + } + }); + + // Create an AudioContext and a ChannelMerger with one input per stream. + const audioContext = new AudioContext(); + const audioDestination = audioContext.createMediaStreamDestination(); + const channelMerger = audioContext.createChannelMerger(mediaStreams.length); + + // Wire each stream's first audio output into its dedicated merger channel. + mediaStreams.forEach((stream, index) => { + const source = audioContext.createMediaStreamSource(stream); + source.connect(channelMerger, 0, index); + }); + + channelMerger.connect(audioDestination); + + return { + stream: audioDestination.stream, + endStream: () => { + audioDestination.stream.getAudioTracks().forEach((track) => track.stop()); + audioContext.close(); + }, + }; +} diff --git a/Web/AmbientScribe/client.ts b/Web/AmbientScribe/client.ts new file mode 100644 index 0000000..8f13034 --- /dev/null +++ b/Web/AmbientScribe/client.ts @@ -0,0 +1,283 @@ +/** + * client.ts — Corti SDK streaming integration for AmbientScribe. + * + * Provides a single entry point — startSession() — that: + * 1. Creates a CortiClient with a stream-scoped access token. + * 2. Connects to the Corti streaming WebSocket. + * 3. Acquires audio depending on the selected mode. + * 4. Streams audio to Corti in 200 ms chunks. + * 5. Emits transcript and fact events via callbacks. + * + * This module has no DOM dependencies — all UI wiring lives in index.html. + */ + +import { CortiClient, CortiEnvironment, type Corti } from "@corti/sdk"; +import { + getMicrophoneStream, + getRemoteParticipantStream, + getDisplayMediaStream, + mergeMediaStreams, +} from "./audio"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Mode = "single" | "virtual"; + +/** How the remote participant's audio is captured in virtual mode. */ +export type RemoteSource = "webrtc" | "display"; + +export interface SessionOptions { + accessToken: string; + interactionId: string; + tenantName: string; + mode: Mode; + remoteSource?: RemoteSource; + peerConnection?: RTCPeerConnection; + onTranscript?: (data: unknown) => void; + onFact?: (data: unknown) => void; +} + +export interface ActiveSession { + endConsultation: () => Promise; +} + +// --------------------------------------------------------------------------- +// startSession +// --------------------------------------------------------------------------- + +/** + * Starts a streaming session in the chosen mode. + * + * 1. Creates a CortiClient using the scoped access token from the server. + * 2. Connects to the streaming WebSocket via client.stream.connect(). + * 3. Acquires the appropriate audio stream(s) depending on the mode. + * 4. Pipes audio to Corti in 200 ms chunks via MediaRecorder. + * 5. Fires onTranscript / onFact callbacks for incoming events. + * + * @returns An object with an `endConsultation()` method for cleanup. + */ +export async function startSession( + options: SessionOptions +): Promise { + const { + accessToken, + interactionId, + tenantName, + mode, + remoteSource = "webrtc", + peerConnection, + onTranscript, + onFact, + } = options; + + // -- 1. Create a client scoped to streaming only ------------------------- + const client = new CortiClient({ + environment: CortiEnvironment.Eu, + tenantName, + auth: { + accessToken, // Token with "stream" scope only + }, + }); + + // With a stream-scoped token these would fail: + // await client.interactions.list(); // outside scope + // await client.transcribe.connect({ id: "..." }); // outside scope + + // -- 2. Set up the streaming configuration ------------------------- + + const participants = mode === "single" ? [ + { + channel: 0, + role: "multiple" + } + ] : [ + { + channel: 0, + role: "doctor" + }, + { + channel: 1, + role: "patient" + } + ]; + + const configuration = { + transcription: { + primaryLanguage: "en-US", + isMultichannel: mode !== "single", + participants, + }, + mode: { + type: "facts", + outputLocale: "en-US", + }, + } as Corti.StreamConfig; + + // -- 3. Connect to the Corti streaming WebSocket ------------------------- + const streamSocket = await client.stream.connect({ id: interactionId, configuration }); + + // -- 4. Acquire audio depending on mode ---------------------------------- + // "single" → just the local microphone + // "virtual" → local mic + remote audio (WebRTC or display), merged + + const microphoneStream = await getMicrophoneStream(); + console.log(`[${mode}] Microphone stream acquired`); + + // audioStream is what we feed into MediaRecorder. + // endMergedStream is only set when we merge (virtual mode). + let audioStream: MediaStream; + let remoteStream: MediaStream | undefined; + let endMergedStream: (() => void) | undefined; + + if (mode === "virtual") { + // Get the remote participant's audio from the chosen source. + if (remoteSource === "display") { + // Screen / tab capture — the browser will show a picker dialog. + // Useful when the video-call runs in another tab and you don't + // have direct access to the peer connection. + remoteStream = await getDisplayMediaStream(); + console.log("[virtual:display] Display media stream acquired"); + } else { + // WebRTC — pull audio tracks from an existing peer connection. + if (!peerConnection) { + throw new Error( + 'Virtual mode with remoteSource "webrtc" requires an RTCPeerConnection' + ); + } + remoteStream = getRemoteParticipantStream(peerConnection); + console.log("[virtual:webrtc] Remote participant stream acquired"); + } + + // Merge: channel 0 = doctor (mic), channel 1 = patient (remote) + const merged = mergeMediaStreams([microphoneStream, remoteStream]); + audioStream = merged.stream; + endMergedStream = merged.endStream; + } else { + audioStream = microphoneStream; + } + + // -- 5. Stream audio to Corti in 250 ms chunks -------------------------- + // Prefer WebM with Opus codec (recommended by Corti), fall back to browser default + const supportedMimeTypes = [ + 'audio/webm;codecs=opus', + 'audio/webm', + 'audio/ogg;codecs=opus', + 'audio/ogg', + ]; + + let mimeType: string | undefined; + for (const type of supportedMimeTypes) { + if (MediaRecorder.isTypeSupported(type)) { + mimeType = type; + break; + } + } + + const mediaRecorder = new MediaRecorder(audioStream, mimeType ? { mimeType } : undefined); + console.log(`[${mode}] MediaRecorder using mimeType: ${mediaRecorder.mimeType || 'browser default'}`); + + let configAccepted = false; + let mediaRecorderStarted = false; + let isEnding = false; + let endedResolver: (() => void) | null = null; + + mediaRecorder.ondataavailable = async (event: BlobEvent) => { + if (event.data.size > 0 && configAccepted && !isEnding) { + // Convert Blob to ArrayBuffer as required by sendAudio + const arrayBuffer = await event.data.arrayBuffer(); + streamSocket.sendAudio(arrayBuffer); + } + }; + + // -- 6. Handle incoming events ------------------------------------------- + streamSocket.on("message", (message) => { + // Wait for CONFIG_ACCEPTED before starting MediaRecorder + if (message.type === "CONFIG_ACCEPTED") { + configAccepted = true; + console.log(`[${mode}] Configuration accepted, starting MediaRecorder`); + if (!mediaRecorderStarted) { + mediaRecorderStarted = true; + mediaRecorder.start(250); + console.log(`[${mode}] MediaRecorder started — streaming audio to Corti`); + } + return; + } + + switch (message.type) { + case "transcript": + console.log("Transcript:", message); + onTranscript?.(message); + break; + case "facts": + console.log("Facts:", message); + onFact?.(message); + break; + case "usage": + console.log("Usage:", message); + break; + case "ENDED": + console.log(`[${mode}] Session ended by server`); + if (endedResolver) { + endedResolver(); + endedResolver = null; + } + break; + default: + console.log("Unhandled message type:", message.type); + break; + } + }); + + // -- 7. Return cleanup function ------------------------------------------ + let endedPromise: Promise | null = null; + + return { + endConsultation: async () => { + // If already ending, wait for the existing promise + if (isEnding && endedPromise) { + await endedPromise; + return; + } + + isEnding = true; + console.log(`[${mode}] Ending consultation...`); + + // Create promise to wait for ENDED message + endedPromise = new Promise((resolve) => { + endedResolver = resolve; + }); + + // Stop recording first + if (mediaRecorder.state !== "inactive") { + // Request any remaining buffered audio before stopping + if (mediaRecorder.state === "recording") { + mediaRecorder.requestData(); + } + mediaRecorder.stop(); + } + + // Send end message to server + streamSocket.sendEnd({ type: "end" }); + + // Wait for ENDED message before cleaning up resources + // The server will send usage message, then ENDED + await endedPromise; + + // Now clean up resources after receiving ENDED + streamSocket.close(); + + // Release the merged stream (virtual mode only) + endMergedStream?.(); + + // Release the remote stream tracks (virtual mode only) + remoteStream?.getAudioTracks().forEach((track) => track.stop()); + + // Release the raw microphone track(s) + microphoneStream.getAudioTracks().forEach((track) => track.stop()); + + console.log(`[${mode}] Consultation ended — all resources cleaned up`); + }, + }; +} diff --git a/Web/AmbientScribe/index.html b/Web/AmbientScribe/index.html new file mode 100644 index 0000000..74685a7 --- /dev/null +++ b/Web/AmbientScribe/index.html @@ -0,0 +1,166 @@ + + + + + Corti AmbientScribe Demo + + + +

AmbientScribe

+ + +
+ Mode + +
+ +
+ + + + + + + + + +

Open the browser console to see transcripts and facts.

+ + + + + + + + diff --git a/Web/AmbientScribe/package-lock.json b/Web/AmbientScribe/package-lock.json new file mode 100644 index 0000000..4f1d10f --- /dev/null +++ b/Web/AmbientScribe/package-lock.json @@ -0,0 +1,1676 @@ +{ + "name": "corti-ambientscribe-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "corti-ambientscribe-demo", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@corti/sdk": "^0.10.1", + "dotenv": "^17.3.1", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "esbuild": "^0.24.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@corti/sdk": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@corti/sdk/-/sdk-0.10.1.tgz", + "integrity": "sha512-3Tal1Lbdel4+illIwPiF3Ln/QRtbKLrqYjd6odByQGMb15d2VCgywcUGatFVVqiAhxmvUykEqgIDhEmkMxK9KQ==", + "license": "MIT", + "dependencies": { + "ws": "^8.16.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/Web/AmbientScribe/package.json b/Web/AmbientScribe/package.json new file mode 100644 index 0000000..36cfa7d --- /dev/null +++ b/Web/AmbientScribe/package.json @@ -0,0 +1,34 @@ +{ + "name": "corti-ambientscribe-demo", + "version": "1.0.0", + "description": "Live audio transcription and clinical document generation demo using Corti SDK", + "main": "server.ts", + "type": "module", + "scripts": { + "build:client": "esbuild client.ts --bundle --outfile=dist/client.js --format=iife --global-name=AmbientScribe", + "dev": "npm run build:client && ts-node --esm server.ts", + "start": "node dist/server.js", + "build": "npm run build:client && tsc", + "clean": "rm -rf dist" + }, + "keywords": [ + "corti", + "audio", + "transcription", + "clinical-documentation" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@corti/sdk": "^0.10.1", + "dotenv": "^17.3.1", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.10.6", + "esbuild": "^0.24.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/Web/AmbientScribe/server.ts b/Web/AmbientScribe/server.ts new file mode 100644 index 0000000..0fedccc --- /dev/null +++ b/Web/AmbientScribe/server.ts @@ -0,0 +1,175 @@ +/** + * server.ts — Express server for AmbientScribe. + * + * Responsible for: + * 1. Creating a fully-privileged CortiClient using OAuth2 client credentials. + * 2. Exposing a POST /api/start-session endpoint that: + * a. Creates an interaction via the Corti REST API. + * b. Mints a scoped stream token (WebSocket access only). + * c. Returns both to the client. + * 3. Serving the static front-end files (index.html, client.ts, audio.ts). + * + * IMPORTANT: Client credentials (CLIENT_ID / CLIENT_SECRET) must NEVER be + * exposed to the browser. Only the scoped stream token is sent to the client. + */ + +import "dotenv/config"; +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; +import { CortiClient, CortiAuth, CortiEnvironment, type Corti } from "@corti/sdk"; +import { randomUUID } from "crypto"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --------------------------------------------------------------------------- +// Configuration — replace with your own values or load from environment +// --------------------------------------------------------------------------- + +const TENANT_NAME = process.env.CORTI_TENANT_NAME ?? "YOUR_TENANT_NAME"; +const CLIENT_ID = process.env.CORTI_CLIENT_ID ?? "YOUR_CLIENT_ID"; +const CLIENT_SECRET = process.env.CORTI_CLIENT_SECRET ?? "YOUR_CLIENT_SECRET"; +const PORT = Number(process.env.PORT ?? 3000); + +// --------------------------------------------------------------------------- +// 1. Create a CortiClient authenticated with client credentials (OAuth2). +// This client has full API access and must only be used server-side. +// --------------------------------------------------------------------------- + +const client = new CortiClient({ + environment: CortiEnvironment.Eu, + tenantName: TENANT_NAME, + auth: { + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + }, +}); + +// --------------------------------------------------------------------------- +// 2. Helper: create an interaction. +// An interaction represents a single clinical encounter / session. +// --------------------------------------------------------------------------- + +async function createInteraction() { + const interaction = await client.interactions.create({ + encounter: { + identifier: randomUUID(), + status: "planned", + type: "first_consultation", + }, + }); + + console.log("Interaction created:", interaction.interactionId); + return interaction; +} + +// --------------------------------------------------------------------------- +// 3. Helper: mint a scoped token with only the "stream" scope. +// This token lets the client connect to the streaming WebSocket but +// cannot list interactions, create documents, or call any other REST +// endpoint — keeping the blast radius minimal if it leaks. +// --------------------------------------------------------------------------- + +async function getScopedStreamToken() { + const auth = new CortiAuth({ + environment: CortiEnvironment.Eu, + tenantName: TENANT_NAME, + }); + + const streamToken = await auth.getToken({ + clientId: CLIENT_ID, + clientSecret: CLIENT_SECRET, + scopes: ["streams"], + }); + + return streamToken; +} + +// --------------------------------------------------------------------------- +// 4. Express app +// --------------------------------------------------------------------------- + +const app = express(); + +// Serve the front-end files (index.html, dist/client.js) from this directory. +app.use(express.static(path.join(__dirname))); +app.use(express.json()); + +// POST /api/start-session +// Creates an interaction + scoped token and returns them to the client. +app.post("/api/start-session", async (_req, res) => { + try { + const interaction = await createInteraction(); + const streamToken = await getScopedStreamToken(); + + // The client only receives the interaction ID, tenant name, and a limited-scope token. + res.json({ + interactionId: interaction.interactionId, + tenantName: TENANT_NAME, + accessToken: streamToken.accessToken, + }); + } catch (err) { + console.error("Failed to start session:", err); + res.status(500).json({ error: "Failed to start session" }); + } +}); + +// --------------------------------------------------------------------------- +// 5. POST /api/create-document +// Fetches the facts collected during the consultation, then generates a +// clinical document from them using the Corti Documents API. +// --------------------------------------------------------------------------- + +app.post("/api/create-document", async (req, res) => { + try { + const { interactionId } = req.body; + + if (!interactionId) { + res.status(400).json({ error: "Missing interactionId" }); + return; + } + + // Step 1: Fetch facts collected during the consultation + const {facts} = await client.facts.list(interactionId); + console.log(`Fetched ${facts.length} facts for interaction ${interactionId}`); + + // Step 2: Map facts into the format expected by the Documents API + const factsContext = facts.map(fact => ({ + text: fact.text, + group: fact.group, + source: fact.source, + })) as Corti.FactsContext[]; + + // Step 3: Create a document using the collected facts + const document = await client.documents.create(interactionId, { + context: [ + { + type: "facts", + data: factsContext, + }, + ], + template: { + sections: [ + { key: "corti-hpi" }, + { key: "corti-allergies" }, + { key: "corti-social-history" }, + { key: "corti-plan" }, + ], + }, + outputLanguage: "en", + name: "Consultation Document", + documentationMode: "routed_parallel", + }); + + console.log("Document created:", document); + res.json({ document }); + } catch (err) { + console.error("Failed to create document:", err); + res.status(500).json({ error: "Failed to create document" }); + } +}); + +app.listen(PORT, () => { + console.log(`AmbientScribe server listening on http://localhost:${PORT}`); +}); diff --git a/Web/AmbientScribe/singleMicrophone.ts b/Web/AmbientScribe/singleMicrophone.ts deleted file mode 100644 index f35ecf8..0000000 --- a/Web/AmbientScribe/singleMicrophone.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { AuthCreds, Config, TranscriptEventData, FactEventData } from "./types"; - - const DEFAULT_CONFIG: Config = { - type: "config", - configuration: { - transcription: { - primaryLanguage: "en", - isDiarization: true, - isMultichannel: false, - participants: [ - { - channel: 0, - role: "multiple", - }, - ], - }, - mode: { - type: "facts", - outputLocale: "en", - }, - }, - }; - - - - /** - * Retrieves the user's microphone MediaStream. - * If a device ID is provided, attempts to use that specific microphone, otherwise uses the default. - * - * @param deviceId - Optional ID of the desired audio input device. - * @returns A Promise that resolves with the MediaStream. - * @throws An error if accessing the microphone fails. - */ -const getMicrophoneStream = async (deviceId?: string): Promise => { - if (!navigator.mediaDevices) { - throw new Error("Media Devices API not supported in this browser"); - } - try { - return await navigator.mediaDevices.getUserMedia({ - audio: deviceId ? { deviceId: { exact: deviceId } } : true, - }); - } catch (error) { - console.error("Error accessing microphone:", error); - throw error; - } - }; - - - /** - * Starts an audio flow by connecting a MediaStream to a WebSocket endpoint and sending a config. - * The flow begins once a CONFIG_ACCEPTED message is received, after which audio - * data is sent in 200ms chunks via a MediaRecorder. - * - * @param mediaStream - The audio MediaStream to send. - * @param authCreds - Authentication credentials containing environment, tenant, and token. - * @param interactionId - The interaction identifier used in the WebSocket URL. - * @param config - Optional configuration object; falls back to DEFAULT_CONFIG if not provided. - * @returns An object with a: - * - `recorderStarted` boolean indicating whether the MediaRecorder has started - * - `stop` method to end the flow and clean up resources - */ - async function startAudioFlow(mediaStream: MediaStream, authCreds: AuthCreds, interactionId: string, handleEvent: (arg0: MessageEvent) => void, config?: Config): Promise<{ recorderStarted: boolean, stop: () => void }> { - // 2. Set up configuration if not provided - if (!config) { - config = DEFAULT_CONFIG; - } - - // 3. Start WebSocket connection - const wsUrl = `wss://api.${authCreds.environment}.corti.app/audio-bridge/v2/interactions/${interactionId}/streams?tenant-name=${authCreds.tenant}&token=Bearer%20${authCreds.token}`; - const ws = new WebSocket(wsUrl); - let isOpen = false; - let recorderStarted = false; - let mediaRecorder: MediaRecorder; - - ws.onopen = () => { - ws.send(JSON.stringify(config)); - isOpen = true; - }; - - // 4. Wait for CONFIG_ACCEPTED message - ws.onmessage = (msg: MessageEvent) => { - try { - const data = JSON.parse(msg.data); - if (data.type === "CONFIG_ACCEPTED" && !recorderStarted) { - recorderStarted = true; - startMediaRecorder(); - } - handleEvent(msg); - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - } - }; - - ws.onerror = (err: Event) => { - console.error("WebSocket encountered an error:", err); - // Optionally, call stop() to clean up resources - }; - - ws.onclose = (event: Event) => { - console.log("WebSocket closed:", event); - // Ensure cleanup is performed or notify the user - }; - - // 5. Start MediaRecorder with 200ms chunks and send data to WebSocket - function startMediaRecorder() { - mediaRecorder = new MediaRecorder(mediaStream); - mediaRecorder.ondataavailable = (event: BlobEvent) => { - if (isOpen) { - ws.send(event.data); - } - }; - mediaRecorder.start(200); - } - - // 6. End the flow - const stop = () => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "end" })); - } - if (mediaRecorder && mediaRecorder.state !== "inactive") { - mediaRecorder.stop(); - } - setTimeout(() => { - ws.close(); - }, 10000); - }; - - return { recorderStarted, stop }; - } - - - - // Usage Example: - // Define authentication credentials and interaction identifier. - const authCreds: AuthCreds = { - environment: "us", - tenant: "your-tenant", - token: "your-token", - }; - const interactionId = "interaction-id"; - - const transcripts: TranscriptEventData[] = []; - const facts: FactEventData[] = []; - - const handleNewMessage = (msg: MessageEvent) => { - try { - const parsed = JSON.parse(msg.data); - - switch (parsed.type) { - case "transcript": - transcripts.push(parsed.data as TranscriptEventData); - break; - case "fact": - facts.push(parsed.data as FactEventData); - break; - default: - console.log("Unhandled WebSocket event type:", parsed.type); - } - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - } - }; - - // Encapsulate the call setup in an async function. - async function startCall() { - try { - // Retrieve the user's microphone stream. - const microphoneStream = await getMicrophoneStream(); - - // Start the audio flow over a WebSocket connection. - // The returned `stop` method is used to end the audio flow gracefully. - const { stop } = await startAudioFlow(microphoneStream, authCreds, interactionId, handleNewMessage); - - // Define a cleanup function to end the call. - const endCall = () => { - // End the audio flow (closes WebSocket and stops MediaRecorder). - stop(); - // Optionally, stop original streams if no longer needed. - microphoneStream.getAudioTracks().forEach((track) => track.stop()); - console.log("Call ended and resources cleaned up."); - }; - - return { endCall }; - } catch (error) { - console.error("Error starting call:", error); - throw error; - } - } - - // Example usage: start a call and end it after 10 seconds. - startCall() - .then(({ endCall }) => { - setTimeout(endCall, 10000); - }) - .catch((error) => { - // Handle any errors that occurred during setup. - console.error(error); - }); - \ No newline at end of file diff --git a/Web/AmbientScribe/tsconfig.json b/Web/AmbientScribe/tsconfig.json new file mode 100644 index 0000000..81a0dfb --- /dev/null +++ b/Web/AmbientScribe/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["*.ts"], + "exclude": ["node_modules"] +} diff --git a/Web/AmbientScribe/types.ts b/Web/AmbientScribe/types.ts deleted file mode 100644 index 5f95576..0000000 --- a/Web/AmbientScribe/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface AuthCreds { - environment: string; - tenant: string; - token: string; -} - -export interface Config { - type: string; - configuration: { - transcription: { - primaryLanguage: string; - isDiarization: boolean; - isMultichannel: boolean; - participants: Array<{ - channel: number; - role: string; - }>; - }; - mode: { - type: string; - outputLocale: string; - }; - }; -} - -export interface TranscriptEventData { - id: string; - start: number; - duration: number; - transcript: string; - isFinal: boolean; - participant: { - channel: number; - role: string; - }; - time: { - start: number; - end: number; - }; -} - -export interface FactEventData { - id: string; - text: string; - createdAt: string; - createdAtTzOffset: string; - evidence?: Array; - group: string; - groupId: string; - isDiscarded: boolean; - source: "core" | "system" | "user"; - updatedAt: string; - updatedAtTzOffset: string; -} - -export interface TranscriptMessage { - type: "transcript"; - data: TranscriptEventData; -} - -export interface FactMessage { - type: "fact"; - data: FactEventData; -} - -export type WSSEvent = TranscriptMessage | FactMessage; diff --git a/Web/AmbientScribe/virtualConsultations.ts b/Web/AmbientScribe/virtualConsultations.ts deleted file mode 100644 index 84cf7fb..0000000 --- a/Web/AmbientScribe/virtualConsultations.ts +++ /dev/null @@ -1,267 +0,0 @@ -import type { AuthCreds, Config, TranscriptEventData, FactEventData } from "./types"; - -const DEFAULT_CONFIG: Config = { - type: "config", - configuration: { - transcription: { - primaryLanguage: "en", - isDiarization: false, - isMultichannel: false, - participants: [ - { - channel: 0, - role: "doctor", - }, - { - channel: 0, - role: "patient", - }, - ], - }, - mode: { - type: "facts", - outputLocale: "en", - }, - }, -}; - -/** - * Merges multiple audio MediaStreams into a single MediaStream and returns both - * the merged MediaStream and a cleanup method. - * The cleanup method stops the merged stream's audio tracks and closes the AudioContext. - * - * @param mediaStreams - Array of MediaStreams to merge. - * @returns An object containing: - * - stream: the merged MediaStream. - * - endStream: A method to end the merged stream and clean up resources. - * @throws Error if no streams are provided or if any stream lacks an audio track. - */ -const mergeMediaStreams = ( - mediaStreams: MediaStream[] -): { stream: MediaStream; endStream: () => void } => { - if (!mediaStreams.length) { - throw new Error("No media streams provided."); - } - - // Validate that each MediaStream has an audio track. - mediaStreams.forEach((stream, index) => { - if (!stream.getAudioTracks().length) { - throw new Error( - `MediaStream at index ${index} does not have an audio track.` - ); - } - }); - - // Each mediastream is added as a new channel in order of the array. - const audioContext = new AudioContext(); - const audioDestination = audioContext.createMediaStreamDestination(); - const channelMerger = audioContext.createChannelMerger(mediaStreams.length); - mediaStreams.forEach((stream, index) => { - const source = audioContext.createMediaStreamSource(stream); - source.connect(channelMerger, 0, index); - }); - channelMerger.connect(audioDestination); - - // Close the audio context and stop all tracks when the stream ends. - const endStream = () => { - audioDestination.stream.getAudioTracks().forEach((track) => { - track.stop(); - }); - audioContext.close(); - }; - - // Return the merged stream and the endStream method. - return { stream: audioDestination.stream, endStream }; -}; - -/** - * Retrieves the user's microphone MediaStream. - * If a device ID is provided, attempts to use that specific microphone, otherwise uses the default. - * - * @param deviceId - Optional ID of the desired audio input device. - * @returns A Promise that resolves with the MediaStream. - * @throws An error if accessing the microphone fails. - */ -export const getMicrophoneStream = async ( - deviceId?: string -): Promise => { - if (!navigator.mediaDevices) { - throw new Error("Media Devices API not supported in this browser"); - } - try { - return await navigator.mediaDevices.getUserMedia({ - audio: deviceId ? { deviceId: { exact: deviceId } } : true, - }); - } catch (error) { - console.error("Error accessing microphone:", error); - throw error; - } -}; - -/** - * Starts an audio flow by connecting a MediaStream to a WebSocket endpoint and sending a config. - * The flow begins once a CONFIG_ACCEPTED message is received, after which audio - * data is sent in 200ms chunks via a MediaRecorder. - * - * @param mediaStream - The audio MediaStream to send. - * @param authCreds - Authentication credentials containing environment, tenant, and token. - * @param interactionId - The interaction identifier used in the WebSocket URL. - * @param config - Optional configuration object; falls back to DEFAULT_CONFIG if not provided. - * @returns An object with a: - * - `recorderStarted` boolean indicating whether the MediaRecorder has started - * - `stop` method to end the flow and clean up resources - */ -async function startAudioFlow( - mediaStream: MediaStream, - authCreds: AuthCreds, - interactionId: string, - handleEvent: (arg0: MessageEvent) => void, - config?: Config -): Promise<{ recorderStarted: boolean; stop: () => void }> { - // 2. Set up configuration if not provided - if (!config) { - config = DEFAULT_CONFIG; - } - - // 3. Start WebSocket connection - const wsUrl = `wss://api.${authCreds.environment}.corti.app/audio-bridge/v2/interactions/${interactionId}/streams?tenant-name=${authCreds.tenant}&token=Bearer%20${authCreds.token}`; - const ws = new WebSocket(wsUrl); - let isOpen = false; - let recorderStarted = false; - let mediaRecorder: MediaRecorder; - - ws.onopen = () => { - ws.send(JSON.stringify(config)); - isOpen = true; - }; - - // 4. Wait for CONFIG_ACCEPTED message - ws.onmessage = (msg: MessageEvent) => { - try { - const data = JSON.parse(msg.data); - if (data.type === "CONFIG_ACCEPTED" && !recorderStarted) { - recorderStarted = true; - startMediaRecorder(); - } - handleEvent(msg); - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - } - }; - - ws.onerror = (err: Event) => { - console.error("WebSocket encountered an error:", err); - // Optionally, call stop() to clean up resources - }; - - ws.onclose = (event: Event) => { - console.log("WebSocket closed:", event); - // Ensure cleanup is performed or notify the user - }; - - // 5. Start MediaRecorder with 200ms chunks and send data to WebSocket - function startMediaRecorder() { - mediaRecorder = new MediaRecorder(mediaStream); - mediaRecorder.ondataavailable = (event: BlobEvent) => { - if (isOpen) { - ws.send(event.data); - } - }; - mediaRecorder.start(200); - } - - // 6. End the flow - const stop = () => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "end" })); - } - if (mediaRecorder && mediaRecorder.state !== "inactive") { - mediaRecorder.stop(); - } - setTimeout(() => { - ws.close(); - }, 10000); - }; - - return { recorderStarted, stop }; -} - - -// Usage Example: -// Define authentication credentials and interaction identifier. -const authCreds: AuthCreds = { - environment: "us", - tenant: "your-tenant", - token: "your-token", -}; -const interactionId = "interaction-id"; - const transcripts: TranscriptEventData[] = []; - const facts: FactEventData[] = []; - - const handleNewMessage = (msg: MessageEvent) => { - try { - const parsed = JSON.parse(msg.data); - - switch (parsed.type) { - case "transcript": - transcripts.push(parsed.data as TranscriptEventData); - break; - case "fact": - facts.push(parsed.data as FactEventData); - break; - default: - console.log("Unhandled WebSocket event type:", parsed.type); - } - } catch (err) { - console.error("Failed to parse WebSocket message:", err); - } - }; - -// Encapsulate the call setup in an async function. -async function startCall() { - try { - // Retrieve the user's microphone stream. - const microphoneStream = await getMicrophoneStream(); - - // Obtain the WebRTC stream (e.g., from a WebRTC connection). - const webRTCStream = new MediaStream(); - - // Merge the microphone and WebRTC streams. - // The order of the streams should match your default configuration. - const { stream, endStream } = mergeMediaStreams([ - microphoneStream, - webRTCStream, - ]); - - // Start the audio flow over a WebSocket connection. - // The returned `stop` method is used to end the audio flow gracefully. - const { stop } = await startAudioFlow(stream, authCreds, interactionId, handleNewMessage); - - // Define a cleanup function to end the call. - const endCall = () => { - // End the audio flow (closes WebSocket and stops MediaRecorder). - stop(); - // Stop the merged stream. - endStream(); - // Optionally, stop original streams if no longer needed. - microphoneStream.getAudioTracks().forEach((track) => track.stop()); - webRTCStream.getAudioTracks().forEach((track) => track.stop()); - console.log("Call ended and resources cleaned up."); - }; - - return { endCall }; - } catch (error) { - console.error("Error starting call:", error); - throw error; - } -} - -// Example usage: start a call and end it after 10 seconds. -startCall() - .then(({ endCall }) => { - setTimeout(endCall, 10000); - }) - .catch((error) => { - // Handle any errors that occurred during setup. - console.error(error); - });