diff --git a/AGENTS.md b/AGENTS.md index 7cfa81e..3e008ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,7 +188,9 @@ console.log('Speech echo WebSocket app listening on port 3000'); Both `WebhookResponse` and `Session` support the same chainable verb methods: -`.say(opts)` `.play(opts)` `.gather(opts)` `.dial(opts)` `.llm(opts)` `.s2s(opts)` `.openai_s2s(opts)` `.google_s2s(opts)` `.elevenlabs_s2s(opts)` `.deepgram_s2s(opts)` `.ultravox_s2s(opts)` `.dialogflow(opts)` `.conference(opts)` `.enqueue(opts)` `.dequeue(opts)` `.hangup()` `.pause(opts)` `.redirect(opts)` `.config(opts)` `.tag(opts)` `.dtmf(opts)` `.listen(opts)` `.transcribe(opts)` `.message(opts)` `.stream(opts)` `.agent(opts)` `.dub(opts)` `.alert(opts)` `.answer(opts)` `.leave()` `.sipDecline(opts)` `.sipRefer(opts)` `.sipRequest(opts)` +`.say(opts)` `.play(opts)` `.gather(opts)` `.dial(opts)` `.llm(opts)` `.s2s(opts)` `.openai_s2s(opts)` `.google_s2s(opts)` `.elevenlabs_s2s(opts)` `.deepgram_s2s(opts)` `.ultravox_s2s(opts)` `.dialogflow(opts)` `.room(opts)` `.enqueue(opts)` `.dequeue(opts)` `.hangup()` `.pause(opts)` `.redirect(opts)` `.config(opts)` `.tag(opts)` `.dtmf(opts)` `.stream(opts)` `.transcribe(opts)` `.message(opts)` `.agent(opts)` `.dub(opts)` `.alert(opts)` `.answer(opts)` `.leave()` `.sipDecline(opts)` `.sipRefer(opts)` `.sipRequest(opts)` + +Prefer `.room(opts)` and `.stream(opts)`. `.conference(opts)` and `.listen(opts)` remain as backward-compatible synonyms (same options), but new apps should use `room`/`stream`. All methods accept the same options as the corresponding verb JSON Schema. Methods are chainable — they return `this`. @@ -415,9 +417,9 @@ session.on('close', (code, reason) => { /* connection closed */ }); session.on('error', (err) => { /* error */ }); ``` -## Audio WebSocket (Listen/Stream) +## Audio WebSocket (Stream/Listen) -The `listen` and `stream` verbs open a separate WebSocket connection from jambonz to your application, carrying raw audio. This is independent of the control WebSocket (`ws.jambonz.org`) — it uses the `audio.drachtio.org` subprotocol. +The `stream` verb (and its backward-compatible synonym `listen`) opens a separate WebSocket connection from jambonz to your application, carrying raw audio. This is independent of the control WebSocket (`ws.jambonz.org`) — it uses the `audio.drachtio.org` subprotocol. ### Receiving Audio in the Same Application @@ -433,14 +435,14 @@ const makeService = createEndpoint({ server, port: 3000 }); // Control pipe — handles call sessions const svc = makeService({ path: '/' }); -// Audio pipe — receives listen/stream audio +// Audio pipe — receives stream audio const audioSvc = makeService.audio({ path: '/audio-stream' }); svc.on('session:new', (session) => { session .answer() .say({ text: 'Recording your audio.' }) - .listen({ + .stream({ url: '/audio-stream', // relative path — jambonz connects back to same server sampleRate: 16000, mixType: 'mono', @@ -472,7 +474,7 @@ The `stream` object in the `connection` event is an `AudioStream` instance: **Events**: - `audio` — L16 PCM binary frame (`Buffer`) -- `dtmf` — `{digit, duration}` (only if `passDtmf: true` on listen verb) +- `dtmf` — `{digit, duration}` (only if `passDtmf: true` on stream verb) - `playDone` — `{id}` (after non-streaming playAudio completes) - `mark` — `{name, event}` where event is `'playout'` or `'cleared'` - `close` — `(code, reason)` @@ -480,7 +482,7 @@ The `stream` object in the `connection` event is an `AudioStream` instance: ### Sending Audio Back (Bidirectional) -The listen verb supports bidirectional audio. There are two modes, controlled by the `bidirectionalAudio.streaming` option on the listen verb. +The stream verb supports bidirectional audio. There are two modes, controlled by the `bidirectionalAudio.streaming` option on the stream verb. **Non-streaming mode** (`streaming: false`, the default) — send complete audio clips as base64: @@ -502,7 +504,7 @@ Up to 10 playAudio commands can be queued simultaneously. **Streaming mode** (`streaming: true`) — send raw binary PCM frames directly: ```typescript -// In the listen verb config: +// In the stream verb config: // bidirectionalAudio: { enabled: true, streaming: true, sampleRate: 16000 } stream.on('audio', (pcm) => { @@ -513,16 +515,16 @@ stream.on('audio', (pcm) => { ### Marks (Synchronization Markers) -Marks let you track when streamed audio has been played out to the caller. They work **only with bidirectional streaming mode** — you must enable `bidirectionalAudio: { enabled: true, streaming: true }` on the listen verb. +Marks let you track when streamed audio has been played out to the caller. They work **only with bidirectional streaming mode** — you must enable `bidirectionalAudio: { enabled: true, streaming: true }` on the stream verb. The pattern is: stream audio via `sendAudio()`, then send a mark. When all the audio sent before the mark finishes playing out, jambonz sends back a mark event with `event: 'playout'`. This is how you know the caller has heard a specific chunk of audio. ```typescript -// Listen verb must enable bidirectional streaming for marks to work +// Stream verb must enable bidirectional streaming for marks to work session - .listen({ + .stream({ url: '/audio', - actionHook: '/listen-done', + actionHook: '/stream-done', bidirectionalAudio: { enabled: true, streaming: true, @@ -557,7 +559,7 @@ audioSvc.on('connection', (stream) => { ```typescript stream.killAudio(); // Stop playback, flush buffer -stream.disconnect(); // Close connection, end listen verb +stream.disconnect(); // Close connection, end stream verb stream.sendMark('sync-pt'); // Insert synchronization marker stream.clearMarks(); // Clear all pending markers stream.close(); // Close the WebSocket @@ -710,7 +712,7 @@ Complete working examples are in the `examples/` directory: - **echo** — Speech echo using gather with actionHook pattern (webhook + WebSocket). The canonical example for understanding actionHook event handling. - **ivr-menu** — Interactive menu with speech and DTMF input (webhook) - **dial** — Simple outbound dial to a phone number (webhook) -- **listen-record** — Record audio using the listen verb to stream to a WebSocket (webhook) +- **stream-record** — Record audio using the stream verb to stream to a WebSocket (webhook) - **voice-agent** — LLM-powered conversational AI with tool calls (webhook + WebSocket) - **openai-realtime** — OpenAI Realtime API voice agent with function calling (WebSocket) - **deepgram-voice-agent** — Deepgram Voice Agent API with function calling (WebSocket) @@ -719,3 +721,8 @@ Complete working examples are in the `examples/` directory: - **queue-with-hold** — Call queue with hold music and agent dequeue (webhook + WebSocket) - **call-recording** — Mid-call recording control via REST API and inject commands (webhook + WebSocket) - **realtime-translator** — Bridges two parties with real-time speech translation using STT, Google Translate, and TTS dub tracks. Multi-file example with `src/routes/` structure (WebSocket) +- **room-with-stream** — A Room with a nested bidirectional audio stream forked to a WebSocket (WebSocket) +- **stream-then-room** — 1:1 stream, then move the caller + stream into a Room mid-call (WebSocket) +- **s2s-move-to-room** — Ultravox s2s, then move the caller + agent into a Room mid-call (WebSocket) +- **room-say** — injectSay a one-shot TTS announcement heard by the whole Room (WebSocket) +- **room-play-tone** — injectPlay a tone heard by the whole Room (WebSocket) diff --git a/README.md b/README.md index 35bce92..556de2a 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ const audioSvc = makeService.audio({ path: '/audio-stream' }); svc.on('session:new', (session) => { session .say({ text: 'Listening...' }) - .listen({ + .stream({ url: '/audio-stream', // relative path — jambonz connects back to same server sampleRate: 8000, bidirectionalAudio: { @@ -369,7 +369,9 @@ import { JambonzClient } from '@jambonz/sdk/client'; Both `WebhookResponse` and WebSocket `Session` support the same chainable verb methods: -`.say()` `.play()` `.gather()` `.dial()` `.llm()` `.conference()` `.enqueue()` `.dequeue()` `.hangup()` `.pause()` `.redirect()` `.config()` `.tag()` `.dtmf()` `.listen()` `.transcribe()` `.message()` `.stream()` `.agent()` `.dub()` `.alert()` `.answer()` `.leave()` `.sipDecline()` `.sipRefer()` `.sipRequest()` +`.say()` `.play()` `.gather()` `.dial()` `.llm()` `.room()` `.enqueue()` `.dequeue()` `.hangup()` `.pause()` `.redirect()` `.config()` `.tag()` `.dtmf()` `.stream()` `.transcribe()` `.message()` `.agent()` `.dub()` `.alert()` `.answer()` `.leave()` `.sipDecline()` `.sipRefer()` `.sipRequest()` + +> Prefer `.room()` and `.stream()`. `.conference()` and `.listen()` are still supported as synonyms for backward compatibility. All methods accept the same options as the corresponding [verb JSON schemas](schema/verbs/) and are chainable. @@ -427,13 +429,18 @@ See the [examples/](examples/) directory: | [echo](examples/echo/) | Webhook + WS | Speech echo using gather with actionHook | | [ivr-menu](examples/ivr-menu/) | Webhook | Interactive menu with speech and DTMF | | [dial](examples/dial/) | Webhook | Outbound dial to a phone number | -| [listen-record](examples/listen-record/) | Webhook | Record audio via WebSocket stream | +| [stream-record](examples/stream-record/) | Webhook | Record audio via WebSocket stream | | [voice-agent](examples/voice-agent/) | Webhook + WS | LLM-powered conversational AI with tool calls | | [openai-realtime](examples/openai-realtime/) | WebSocket | OpenAI Realtime API voice agent | | [deepgram-voice-agent](examples/deepgram-voice-agent/) | WebSocket | Deepgram Voice Agent API | | [llm-streaming](examples/llm-streaming/) | WebSocket | Anthropic LLM with TTS streaming and barge-in | | [queue-with-hold](examples/queue-with-hold/) | Webhook + WS | Call queue with hold music | | [call-recording](examples/call-recording/) | Webhook + WS | Mid-call recording control | +| [room-with-stream](examples/room-with-stream/) | WebSocket | A Room with a nested bidirectional audio stream | +| [stream-then-room](examples/stream-then-room/) | WebSocket | 1:1 stream, then move caller + stream into a Room | +| [s2s-move-to-room](examples/s2s-move-to-room/) | WebSocket | Ultravox s2s, then move caller + agent into a Room | +| [room-say](examples/room-say/) | WebSocket | injectSay a one-shot announcement heard by the whole Room | +| [room-play-tone](examples/room-play-tone/) | WebSocket | injectPlay a tone heard by the whole Room | ## Publishing to npm diff --git a/examples/bedrock-agent.ts b/examples/bedrock-agent.ts index a9766bb..0d74b75 100644 --- a/examples/bedrock-agent.ts +++ b/examples/bedrock-agent.ts @@ -25,6 +25,11 @@ const envVars = { description: 'ElevenLabs voice id', default: 'hpp4J3VqNfWAUOO0d1Us', }, + TAVILY_API_KEY: { + type: 'string' as const, + description: 'Tavily API key for the web_search tool', + obscure: true, + }, SYSTEM_PROMPT: { type: 'string' as const, description: 'System prompt for the voice agent', @@ -136,7 +141,7 @@ function handleSession(session: Session, opts: AgentOptions) { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ - api_key: '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', + api_key: session.data.env_vars?.TAVILY_API_KEY, query, max_results: 3, search_depth: 'basic', diff --git a/examples/room-play-tone/ws-app.ts b/examples/room-play-tone/ws-app.ts new file mode 100644 index 0000000..6aba65d --- /dev/null +++ b/examples/room-play-tone/ws-app.ts @@ -0,0 +1,63 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Play a tone (or any file/URL) into a Room, heard by every member, mid-call. + * + * The caller joins a Room. PLAY_DELAY_MS after joining, we call + * session.injectPlay({...}) — the audio is mixed into the Room and heard by all + * members. The optional `id` is echoed back on the play-start / play-done events. + * + * The room verb subscribes to the play lifecycle via statusEvents + statusHook. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + PLAY_URL: { type: 'string', description: 'audio to play into the Room (file/http url or tone://)', default: 'tone://?freq=880&duration=400' }, + PLAY_DELAY_MS: { type: 'number', description: 'ms after joining before playing', default: 5000 }, + }, +}); + +const svc = makeService({ path: '/room-play-tone' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const room = env.ROOM_NAME || 'demo-room'; + const url = env.PLAY_URL || 'tone://?freq=880&duration=400'; + const playDelayMs = parseInt(env.PLAY_DELAY_MS ?? '5000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}', playing a tone in ${playDelayMs}ms`); + + let played = false; + session + .on('/room-status', (evt: Record) => { + console.log(`room status: ${evt?.event}`, { playId: evt?.play_id, id: evt?.id, reason: evt?.reason }); + // play once the caller has joined the Room + if ((evt?.event === 'join' || evt?.event === 'start') && !played) { + played = true; + setTimeout(() => { + console.log('>>> injectPlay into the Room'); + session.injectPlay({ url, id: 'tone' }); + }, Math.max(0, playDelayMs)); + } + }) + .on('/room-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + actionHook: '/room-done', + statusHook: '/room-status', + statusEvents: ['start', 'end', 'join', 'leave', 'play-start', 'play-done'], + }) + .send(); +}); + +console.log('room-play-tone listening on port 3000 (path /room-play-tone)'); diff --git a/examples/room-say/ws-app.ts b/examples/room-say/ws-app.ts new file mode 100644 index 0000000..9708e7d --- /dev/null +++ b/examples/room-say/ws-app.ts @@ -0,0 +1,67 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Speak TTS into a Room, heard by every member, mid-call. + * + * The caller joins a Room. SAY_DELAY_MS after joining, we call + * session.injectSay({...}) — a one-shot announcement synthesized into the Room and + * heard by all members (not just the caller). The optional `id` is echoed back on + * the say-start / say-done events so you can correlate them. + * + * The room verb subscribes to the say lifecycle via statusEvents + statusHook; + * say-start fires when audio begins, say-done when it finishes. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + SAY_TEXT: { type: 'string', description: 'text to announce into the Room', default: 'Welcome — this announcement is heard by everyone in the room.' }, + SAY_VENDOR: { type: 'string', description: 'TTS vendor (optional; account default if unset)', required: false }, + SAY_DELAY_MS: { type: 'number', description: 'ms after joining before the announcement', default: 5000 }, + }, +}); + +const svc = makeService({ path: '/room-say' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const room = env.ROOM_NAME || 'demo-room'; + const text = env.SAY_TEXT || 'Welcome — this announcement is heard by everyone in the room.'; + const synthesizer = env.SAY_VENDOR ? { vendor: env.SAY_VENDOR } : undefined; + const sayDelayMs = parseInt(env.SAY_DELAY_MS ?? '5000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}', announcing in ${sayDelayMs}ms`); + + let announced = false; + session + .on('/room-status', (evt: Record) => { + console.log(`room status: ${evt?.event}`, { sayId: evt?.say_id, id: evt?.id, reason: evt?.reason }); + // announce once the caller has joined the Room + if ((evt?.event === 'join' || evt?.event === 'start') && !announced) { + announced = true; + setTimeout(() => { + console.log('>>> injectSay into the Room'); + session.injectSay({ text, id: 'announcement', ...(synthesizer && { synthesizer }) }); + }, Math.max(0, sayDelayMs)); + } + }) + .on('/room-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + actionHook: '/room-done', + statusHook: '/room-status', + statusEvents: ['start', 'end', 'join', 'leave', 'say-start', 'say-done'], + }) + .send(); +}); + +console.log('room-say listening on port 3000 (path /room-say)'); diff --git a/examples/room-with-stream/ws-app.ts b/examples/room-with-stream/ws-app.ts new file mode 100644 index 0000000..79189c1 --- /dev/null +++ b/examples/room-with-stream/ws-app.ts @@ -0,0 +1,64 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * A Room with a nested audio stream. + * + * The caller is answered straight into a Room whose `stream` property forks the + * room's mixed audio to a WebSocket endpoint. With bidirectionalAudio enabled, + * whatever the endpoint streams back is mixed into the room and heard by every + * member — so the stream socket is effectively a participant in the Room. + * + * (`stream` is the preferred synonym for the nested `listen` property.) + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + STREAM_WS_URL: { type: 'string', description: 'WebSocket URL to stream the room audio to', required: true }, + ROOM_NAME: { type: 'string', description: 'Room name', default: 'demo-room' }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the stream', default: 8000 }, + }, +}); + +const svc = makeService({ path: '/room-with-stream' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const streamUrl = env.STREAM_WS_URL; + const room = env.ROOM_NAME || 'demo-room'; + const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); + + console.log(`Incoming call ${session.callSid} -> Room '${room}' with a nested audio stream`); + if (!streamUrl) { + console.error('STREAM_WS_URL is not configured as an application environment variable'); + return; + } + + session + .on('/room-done', () => session.reply()) // room verb completed (caller left) + .on('/stream-event', (evt: Record) => console.log('stream event:', evt?.type)) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .room({ + name: room, + beep: true, + actionHook: '/room-done', + // nested stream: fork the room's audio to STREAM_WS_URL; audio streamed back + // is mixed into the room (bidirectional). + stream: { + url: streamUrl, + sampleRate, + bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, + actionHook: '/stream-event', + }, + }) + .send(); +}); + +console.log('room-with-stream listening on port 3000 (path /room-with-stream)'); diff --git a/examples/s2s-move-to-room/ws-app.ts b/examples/s2s-move-to-room/ws-app.ts new file mode 100644 index 0000000..4fe8c35 --- /dev/null +++ b/examples/s2s-move-to-room/ws-app.ts @@ -0,0 +1,82 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Connect a caller to an Ultravox speech-to-speech agent, then move BOTH the + * caller and the agent into a Room — keeping the same conversation and the vendor + * WebSocket alive. + * + * The caller is answered into a 1:1 Ultravox s2s session. Once the agent is live + * (first event), we wait MOVE_DELAY_MS and issue one live-call-control command: + * llm:status { llm_status: 'move-to-room', room } + * jambonz joins the caller into the Room (created if needed) and re-homes the + * agent's s2s engine into the Room as a member — no reconnect, the agent now + * hears (and is heard by) the whole Room. + * + * Set MOVE_DELAY_MS=0 to land the caller + agent in the Room immediately. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + ULTRAVOX_API_KEY: { type: 'string', description: 'Ultravox API key', required: true, obscure: true }, + ULTRAVOX_AGENT_ID: { type: 'string', description: 'Ultravox agent id', required: true }, + ROOM_NAME: { type: 'string', description: 'Room to move into', default: 'agent-room' }, + MOVE_DELAY_MS: { type: 'number', description: 'ms after the agent connects before moving into a Room', default: 15000 }, + }, +}); + +const svc = makeService({ path: '/s2s-move-to-room' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const apiKey = env.ULTRAVOX_API_KEY; + const agentId = env.ULTRAVOX_AGENT_ID; + const room = env.ROOM_NAME || 'agent-room'; + const moveDelayMs = parseInt(env.MOVE_DELAY_MS ?? '15000', 10); + + console.log(`Incoming call ${session.callSid} -> Ultravox s2s; moving into Room '${room}' ${moveDelayMs}ms after connect`); + if (!apiKey || !agentId) { + console.error('ULTRAVOX_API_KEY and ULTRAVOX_AGENT_ID must be configured as application environment variables'); + session.say({ text: 'Configuration error.' }).hangup().send(); + return; + } + + let armed = false; + let moved = false; + const moveToRoom = () => { + if (moved) return; + moved = true; + console.log(`>>> moving caller + agent into Room '${room}' (vendor WebSocket preserved)`); + session.injectCommand('llm:status', { llm_status: 'move-to-room', room }); + }; + + session + .on('/event', () => { + // the first agent event fires only after the vendor WebSocket connects, so + // the s2s engine exists server-side; arm the move timer from here. + if (!armed) { + armed = true; + setTimeout(moveToRoom, Math.max(0, moveDelayMs)); + } + }) + .on('/final', () => session.reply()) // s2s (llm) verb completed + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + session + .answer() + .ultravox_s2s({ + auth: { apiKey, agent_id: agentId }, + actionHook: '/final', + eventHook: '/event', + events: ['all'], + llmOptions: {}, + }) + .hangup() + .send(); +}); + +console.log('s2s-move-to-room listening on port 3000 (path /s2s-move-to-room)'); diff --git a/examples/listen-record/app.ts b/examples/stream-record/app.ts similarity index 84% rename from examples/listen-record/app.ts rename to examples/stream-record/app.ts index 8bdcff0..7706b25 100644 --- a/examples/listen-record/app.ts +++ b/examples/stream-record/app.ts @@ -14,9 +14,9 @@ app.post('/record', (_req, res) => { .say({ text: 'Hi there. Please leave a message, and we will get back to you shortly.', }) - .listen({ url: wsUrl }); + .stream({ url: wsUrl }); res.json(jambonz); }); -app.listen(3000, () => console.log('Listen-record webhook app listening on port 3000')); +app.listen(3000, () => console.log('Stream-record webhook app listening on port 3000')); diff --git a/examples/stream-then-room/ws-app.ts b/examples/stream-then-room/ws-app.ts new file mode 100644 index 0000000..0a38b52 --- /dev/null +++ b/examples/stream-then-room/ws-app.ts @@ -0,0 +1,74 @@ +import http from 'http'; +import { createEndpoint } from '@jambonz/sdk/websocket'; + +/* + * Start a 1:1 audio stream, then move the caller AND the live stream into a Room + * — without dropping the stream's WebSocket. + * + * The caller is answered into a bidirectional `stream` (1:1 with the WS endpoint). + * MOVE_DELAY_MS later we issue one live-call-control command: + * stream:status { stream_status: 'move-to-room', room } + * jambonz joins the caller into the Room (creating it if needed) and re-homes the + * existing stream into the Room as a member — the same socket keeps streaming, now + * mixed for everyone in the Room. + * + * The stream verb MUST carry an `id` to be a managed (movable) stream. + */ + +const server = http.createServer(); +const makeService = createEndpoint({ + server, + port: 3000, + envVars: { + STREAM_WS_URL: { type: 'string', description: 'WebSocket URL for the audio stream', required: true }, + ROOM_NAME: { type: 'string', description: 'Room to move into', default: 'demo-room' }, + SAMPLE_RATE: { type: 'number', description: 'audio sample rate for the stream', default: 8000 }, + MOVE_DELAY_MS: { type: 'number', description: 'ms after answer before moving into the Room', default: 10000 }, + }, +}); + +const svc = makeService({ path: '/stream-then-room' }); + +svc.on('session:new', (session) => { + const env = session.data.env_vars || {}; + const streamUrl = env.STREAM_WS_URL; + const room = env.ROOM_NAME || 'demo-room'; + const sampleRate = parseInt(env.SAMPLE_RATE ?? '8000', 10); + const moveDelayMs = parseInt(env.MOVE_DELAY_MS ?? '10000', 10); + + console.log(`Incoming call ${session.callSid} -> 1:1 stream, moving into Room '${room}' in ${moveDelayMs}ms`); + if (!streamUrl) { + console.error('STREAM_WS_URL is not configured as an application environment variable'); + return; + } + + let moved = false; + const moveToRoom = () => { + if (moved) return; + moved = true; + console.log(`>>> moving caller + stream into Room '${room}' (WebSocket preserved)`); + // server-side this bundles the caller-join and adopts the stream as a Room member. + session.injectCommand('stream:status', { stream_status: 'move-to-room', room }); + }; + + session + .on('/stream-done', () => session.reply()) + .on('close', (code: number) => console.log(`session ${session.callSid} closed: ${code}`)) + .on('error', (err: Error) => console.error('session error:', err)); + + // the stream connects well within the delay, so it exists by the time we move + setTimeout(moveToRoom, Math.max(0, moveDelayMs)); + + session + .answer() + .stream({ + id: 'caller-stream', // managed => movable + url: streamUrl, + sampleRate, + bidirectionalAudio: { enabled: true, streaming: true, sampleRate }, + actionHook: '/stream-done', + }) + .send(); +}); + +console.log('stream-then-room listening on port 3000 (path /stream-then-room)'); diff --git a/typescript/package-lock.json b/typescript/package-lock.json index 791275a..f82f8f3 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.7.0", "license": "MIT", "dependencies": { - "@jambonz/schema": "^0.3.8", + "@jambonz/schema": "^0.3.15", "ajv": "^8.17.1", "ws": "^8.18.0" }, @@ -582,9 +582,9 @@ } }, "node_modules/@jambonz/schema": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.8.tgz", - "integrity": "sha512-NOLRlzLP8x4OhCRQ9sE2jXsg6khqPZo7ehaQ110dnox72Dxi08tt3eOv9q2h8nOF3bm0yyq3rk91/Qptyzxzvg==", + "version": "0.3.15", + "resolved": "https://registry.npmjs.org/@jambonz/schema/-/schema-0.3.15.tgz", + "integrity": "sha512-MvDVBUCUgRk+cs/Qjl6GT/LhgBdY5ze5rgSOY6QOUaWd72dBYNAFZw4au8MYXZPIWpvP9iaHTU5G4Ro5cXIFlQ==", "license": "MIT", "dependencies": { "ajv": "^8.17.1", diff --git a/typescript/package.json b/typescript/package.json index fbfc0ea..56043af 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -98,7 +98,7 @@ "postpublish": "npm run clean-docs" }, "dependencies": { - "@jambonz/schema": "^0.3.8", + "@jambonz/schema": "^0.3.15", "ajv": "^8.17.1", "ws": "^8.18.0" }, diff --git a/typescript/src/types/llm-vendors.generated.ts b/typescript/src/types/llm-vendors.generated.ts index 60afb0f..3b311e1 100644 --- a/typescript/src/types/llm-vendors.generated.ts +++ b/typescript/src/types/llm-vendors.generated.ts @@ -1,5 +1,5 @@ // AUTO-GENERATED — DO NOT EDIT BY HAND. -// Source of truth: @jambonz/schema@0.3.8 verbs/agent.schema.json (llm.vendor.enum) +// Source of truth: @jambonz/schema@0.3.15 verbs/agent.schema.json (llm.vendor.enum) // Regenerate with: npm run gen:types // // This file derives the LLM vendor list from the JSON schema so the SDK's diff --git a/typescript/src/types/verbs.ts b/typescript/src/types/verbs.ts index 205e6d0..f25855d 100644 --- a/typescript/src/types/verbs.ts +++ b/typescript/src/types/verbs.ts @@ -534,7 +534,9 @@ export interface ConferenceVerb { enterHook?: ActionHook; /** Conference recording config. */ record?: Record; - /** Audio streaming config. */ + /** Fork the room's mixed audio to a WebSocket. Prefer `stream`; `listen` is a synonym. */ + stream?: Omit; + /** Audio streaming config (synonym for `stream`). */ listen?: Omit; /** Distribute DTMF to all participants. */ distributeDtmf?: boolean;