From 6e1a9f7345bb0434d44d5e356abc9400fa5427b8 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:25:40 -0400 Subject: [PATCH 01/16] feat: guard handleMediaSubscribe on mediaStream presence for inbound call flow onStreamAvailable now fires twice: first with callId (WS notification, no mediaStream) for call-arrival UI, then with mediaStream (WebRTC ontrack) for audio setup. Guard both handlers so audio setup only runs on the second fire. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/MediaPlayer.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 7c56eb2..3dacefd 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -9,16 +9,21 @@ function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClien throw new Error("setInCall is required"); } - // Register inbound media handler + // Fires twice: first on WS notification (callId present, no mediaStream) for + // call-arrival UI; second on WebRTC ontrack (mediaStream present) for audio. bandwidthRtcClient.onStreamAvailable(async (s) => { console.log("Stream available:", s) setInCall(true); - await handleMediaSubscribe(s) + if (s.mediaStream) { + await handleMediaSubscribe(s) + } }); bandwidthRtcClient.onStreamUnavailable(async (s) => { console.log("Stream unavailable:", s) setInCall(false); - await handleMediaUnsubscribe(s) + if (s.mediaStream) { + await handleMediaUnsubscribe(s) + } }) const audioRef = useRef(null); From 36c493ffd4feed46b07ea8f38feebfca7a17b232 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:34:43 -0400 Subject: [PATCH 02/16] feat: surface incoming call notification with Accept/Decline UI - App.tsx owns all stream event registration (single onStreamAvailable handler); routes WS notification (callId only) to incoming call banner, routes WebRTC ontrack (mediaStream) to MediaPlayer for audio setup - Incoming call banner with Accept/Decline buttons appears on streamAvailable WS notification; accept calls acceptStream(callId), decline calls declineStream(callId) - MediaPlayer simplified: accepts inboundStream prop, wires audio via useEffect instead of relying on the stream callback directly Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 67 +++++++++++++++- src/components/MediaPlayer.tsx | 135 ++++++++------------------------- 2 files changed, 95 insertions(+), 107 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 5461f54..1e75b6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ function App() { const [brtcClientReady, setBrtcClientReady] = useState(false); const [readyMetadata, setReadyMetadata] = useState(null); const [inCall, setInCall] = useState(false); + const [incomingCallId, setIncomingCallId] = useState(null); + const [inboundStream, setInboundStream] = useState(null); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -38,11 +40,42 @@ function App() { prepBrtcClient(false) }, []); + useEffect(() => { + if (!brtcClient) return; + brtcClient.onStreamAvailable((s) => { + console.log("Stream available:", s); + if (s.callId && !s.mediaStream) { + setIncomingCallId(s.callId); + } else if (s.mediaStream) { + setInCall(true); + setInboundStream(s.mediaStream); + } + }); + brtcClient.onStreamUnavailable((s) => { + console.log("Stream unavailable:", s); + setInCall(false); + setIncomingCallId(null); + setInboundStream(null); + }); + }, [brtcClient]); + + const handleAccept = async () => { + if (!brtcClient) return; + await brtcClient.acceptStream(incomingCallId ?? undefined); + setInCall(true); + setIncomingCallId(null); + }; + + const handleDecline = async () => { + if (!brtcClient) return; + await brtcClient.declineStream(incomingCallId ?? undefined); + setIncomingCallId(null); + }; + const resetClient = async () => { await prepBrtcClient(true) } - // Fetch the WebSocket URL from env const gatewayUrl = process.env.REACT_APP_WSS_URL; return ( @@ -53,12 +86,42 @@ function App() {
{brtcClientReady} + {incomingCallId && ( +
+ Incoming call +
+ + +
+
+ )} {readyMetadata && ( <>

Bandwidth RTC Agent Sample

- +
diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 3dacefd..e6f8dec 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -1,54 +1,44 @@ import React, {useEffect, useRef, useState} from "react"; -import BandwidthRtc, {RtcStream} from "bandwidth-rtc"; - -function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClient: BandwidthRtc, inCall: boolean, setInCall: (inCall: boolean) => void} ) { - if (!bandwidthRtcClient) { - throw new Error("webrtcClient is required"); - } - if (!setInCall) { - throw new Error("setInCall is required"); - } - - // Fires twice: first on WS notification (callId present, no mediaStream) for - // call-arrival UI; second on WebRTC ontrack (mediaStream present) for audio. - bandwidthRtcClient.onStreamAvailable(async (s) => { - console.log("Stream available:", s) - setInCall(true); - if (s.mediaStream) { - await handleMediaSubscribe(s) - } - }); - bandwidthRtcClient.onStreamUnavailable(async (s) => { - console.log("Stream unavailable:", s) - setInCall(false); - if (s.mediaStream) { - await handleMediaUnsubscribe(s) - } - }) +function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { const audioRef = useRef(null); const canvasRef = useRef(null); - const [audioSource, setAudioSource] = useState(null); const [audioSourceNode, setAudioSourceNode] = useState(null); const [audioContext, setAudioContext] = useState(null); const [analyser, setAnalyser] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false); - const [dataArray, setDataArray] = useState(new Uint8Array(512)); - const [fftSize, setFftSize] = useState(512); + const [dataArray] = useState(new Uint8Array(512)); const [directMediaStream, setDirectMediaStream] = useState(null); const [localOutputAudioNode, setLocalOutputAudioNode] = useState(undefined); - const [outputStreamSourceNode, setOutputStreamSourceNode] = useState(undefined); useEffect(() => { - // Initialize Web Audio API const context = new window.AudioContext(); const analyserNode = context.createAnalyser(); - analyserNode.fftSize = fftSize; + analyserNode.fftSize = 512; setAudioContext(context); setAnalyser(analyserNode); }, []); + useEffect(() => { + if (inboundStream && audioContext && analyser && audioRef.current && !isSubscribed) { + const sourceNode = audioContext.createMediaStreamSource(inboundStream); + const destination = audioContext.createMediaStreamDestination(); + sourceNode.connect(analyser); + analyser.connect(destination); + drawFFT(); + audioRef.current.srcObject = inboundStream; + setAudioSourceNode(sourceNode); + setIsSubscribed(true); + audioContext.resume(); + setDirectMediaStream(destination.stream); + } else if (!inboundStream && isSubscribed) { + setIsSubscribed(false); + setAudioSourceNode(null); + setDirectMediaStream(null); + } + }, [inboundStream, audioContext, analyser]); + const drawFFT = () => { if (!analyser || !canvasRef.current) return; let bgColor = "rgb(200 200 200)"; @@ -61,35 +51,28 @@ function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClien } } if (!drawFft) { - // Clear the FFT bgColor = "rgb(200 200 200)"; lineColor = "rgb(200 200 200)"; } - const canvas = canvasRef.current; const ctx = canvas.getContext("2d"); - if (!ctx) return + if (!ctx) return; analyser.getByteTimeDomainData(dataArray); - ctx.fillStyle = bgColor; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); - ctx.lineWidth = 2; ctx.strokeStyle = lineColor; ctx.beginPath(); - const sliceWidth = (ctx.canvas.width * 1.0) / dataArray.length; let x = 0; for (let i = 0; i < dataArray.length; i++) { const v = dataArray[i] / 128.0; const y = (v * ctx.canvas.height) / 2; - if (i === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } - x += sliceWidth; } ctx.lineTo(ctx.canvas.width, ctx.canvas.height / 2); @@ -97,74 +80,16 @@ function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClien requestAnimationFrame(drawFFT); }; - const handleMediaSubscribe = async (rtcStream: RtcStream): Promise => { - if (!audioContext || !analyser) { - throw new Error("Audio context or analyser is not initialized"); - } - if (!audioRef.current) { - throw new Error("Audio element is not initialized"); - } - if (!isSubscribed) { - let sourceNode = audioContext.createMediaStreamSource(rtcStream.mediaStream); - let destination = audioContext.createMediaStreamDestination(); - - sourceNode.connect(analyser); - analyser.connect(destination); - let stream = destination.stream; - drawFFT(); - audioRef.current.srcObject = rtcStream.mediaStream; - setAudioSourceNode(sourceNode); - setIsSubscribed(true); - await audioContext.resume() - setDirectMediaStream(stream) - return stream; - } else { - throw new Error("Already subscribed to media"); - } - } - - const handleMediaUnsubscribe = async (s: RtcStream) => { - console.log("Unsubscribing from stream:", s.mediaStream.id); - // Stop playing if we are - if (isPlaying) { - await handlePlay() - } - if (isSubscribed) { - setIsSubscribed(false); - setAudioSourceNode(null); - setDirectMediaStream(null); - } - } - const handlePlay = async () => { - console.log("handlePlay") - let sourceNode = audioSourceNode; - if (!sourceNode) { - console.log("sourceNode is null") - return - } - if (!audioContext) { - throw new Error("Audio context is not initialized"); - return - } - if (!directMediaStream) { - console.log("directMediaStream is null") - return - } - if (!sourceNode) { - sourceNode = audioContext.createMediaStreamSource(directMediaStream) - // audioRef.current.srcObject = mediaStream; - setOutputStreamSourceNode(sourceNode) - } + const sourceNode = audioSourceNode; + if (!sourceNode || !audioContext || !directMediaStream) return; if (!localOutputAudioNode) { - setLocalOutputAudioNode(sourceNode.connect(audioContext.destination)) - setIsPlaying(true) + setLocalOutputAudioNode(sourceNode.connect(audioContext.destination)); + setIsPlaying(true); } else { - if (localOutputAudioNode) { - sourceNode.disconnect(audioContext.destination); - setLocalOutputAudioNode(undefined) - setIsPlaying(false) - } + sourceNode.disconnect(audioContext.destination); + setLocalOutputAudioNode(undefined); + setIsPlaying(false); } }; From fca3cdb97454a6eb225b6330a14fd847c3377c45 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:42:04 -0400 Subject: [PATCH 03/16] fix: use file: reference for bandwidth-rtc to pick up local SDK changes npm link symlinks are not followed by react-scripts' bundled TypeScript. A file: reference causes npm install to copy the built dist/ directly into node_modules so CRA resolves types correctly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package-lock.json | 397 ++++------------------------------------------ package.json | 2 +- 2 files changed, 36 insertions(+), 363 deletions(-) diff --git a/package-lock.json b/package-lock.json index 28a18f7..6117613 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bandwidth-rtc": "^0.2.0", + "bandwidth-rtc": "file:../javascript-brtc-sdk", "bandwidth-sdk": "^7.3.0", "cors": "^2.8.5", "dotenv": "^16.4.7", @@ -79,6 +79,38 @@ "../../pv-in-app-node/dist": { "extraneous": true }, + "../javascript-brtc-sdk": { + "name": "bandwidth-rtc", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "async-mutex": "^0.5.0", + "jwt-decode": "^4.0.0", + "rpc-websockets": "7.10.0", + "uuid": "^13.0.0", + "webrtc-adapter": "^9.0.3" + }, + "devDependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-proposal-class-properties": "^7.8.3", + "@babel/preset-env": "^7.28.3", + "@babel/preset-typescript": "^7.27.1", + "@types/jest": "^30.0.0", + "@types/node": "^24.7.0", + "babel-jest": "^30.2.0", + "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", + "prettier": "^3.6.2", + "ts-loader": "^9.5.4", + "ts-node": "^10.9.2", + "typedoc": "^0.28.13", + "typescript": "^5.9.3", + "webpack": "^5.102.1", + "webpack-cli": "^6.0.1", + "webpack-merge": "^6.0.1" + } + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -2005,30 +2037,6 @@ "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==" }, - "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==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/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==", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, "node_modules/@csstools/normalize.css": { "version": "12.1.1", "resolved": "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz", @@ -2467,7 +2475,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "aix" @@ -2483,7 +2490,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "android" @@ -2499,7 +2505,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2515,7 +2520,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2531,7 +2535,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2547,7 +2550,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2563,7 +2565,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2579,7 +2580,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2595,7 +2595,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2611,7 +2610,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2627,7 +2625,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2643,7 +2640,6 @@ "cpu": [ "loong64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2659,7 +2655,6 @@ "cpu": [ "mips64el" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2675,7 +2670,6 @@ "cpu": [ "ppc64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2691,7 +2685,6 @@ "cpu": [ "riscv64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2707,7 +2700,6 @@ "cpu": [ "s390x" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2723,7 +2715,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2739,7 +2730,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -2755,7 +2745,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "netbsd" @@ -2771,7 +2760,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -2787,7 +2775,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "openbsd" @@ -2803,7 +2790,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "openharmony" @@ -2819,7 +2805,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "sunos" @@ -2835,7 +2820,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2851,7 +2835,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -2867,7 +2850,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3774,7 +3756,6 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", - "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -3813,7 +3794,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -3833,7 +3813,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -3853,7 +3832,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -3873,7 +3851,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -3893,7 +3870,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3913,7 +3889,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3933,7 +3908,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3953,7 +3927,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3973,7 +3946,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -3993,7 +3965,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -4013,7 +3984,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -4033,7 +4003,6 @@ "cpu": [ "ia32" ], - "dev": true, "optional": true, "os": [ "win32" @@ -4053,7 +4022,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -4561,34 +4529,6 @@ "node": ">=10.13.0" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "optional": true, - "peer": true - }, - "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==", - "optional": true, - "peer": true - }, - "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==", - "optional": true, - "peer": true - }, - "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==", - "optional": true, - "peer": true - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -4942,12 +4882,6 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -5815,15 +5749,6 @@ "node": ">= 0.4" } }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6174,31 +6099,8 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/bandwidth-rtc": { - "version": "0.2.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/bandwidth-rtc/-/bandwidth-rtc-0.2.0.tgz", - "integrity": "sha512-7Ourwl89h/M/UYz8NHBGCA9jKFvwvR1s/aB8RZYHRetx6R/lz2cN4iqErwfHPuuPhedoI4r1hvNWgGlDeJnGRw==", - "license": "MIT", - "dependencies": { - "@types/uuid": "^10.0.0", - "async-mutex": "^0.5.0", - "jwt-decode": "^4.0.0", - "rpc-websockets": "7.10.0", - "uuid": "^13.0.0", - "webrtc-adapter": "^9.0.3" - } - }, - "node_modules/bandwidth-rtc/node_modules/uuid": { - "version": "13.0.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/uuid/-/uuid-13.0.0.tgz", - "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } + "resolved": "../javascript-brtc-sdk", + "link": true }, "node_modules/bandwidth-sdk": { "version": "7.3.0", @@ -6369,20 +6271,6 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, - "node_modules/bufferutil": { - "version": "4.1.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/bufferutil/-/bufferutil-4.1.0.tgz", - "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", @@ -6966,13 +6854,6 @@ "node": ">=10" } }, - "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==", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -7542,7 +7423,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, "optional": true, "bin": { "detect-libc": "bin/detect-libc.js" @@ -7598,16 +7478,6 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, "node_modules/diff-sequences": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", @@ -11806,15 +11676,6 @@ "node": ">=4.0" } }, - "node_modules/jwt-decode": { - "version": "4.0.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/jwt-decode/-/jwt-decode-4.0.0.tgz", - "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -12036,13 +11897,6 @@ "semver": "bin/semver.js" } }, - "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==", - "optional": true, - "peer": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -12317,7 +12171,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, "optional": true }, "node_modules/node-forge": { @@ -12328,18 +12181,6 @@ "node": ">= 6.13.0" } }, - "node_modules/node-gyp-build": { - "version": "4.8.4", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/node-gyp-build/-/node-gyp-build-4.8.4.tgz", - "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "license": "MIT", - "optional": true, - "bin": { - "node-gyp-build": "bin.js", - "node-gyp-build-optional": "optional.js", - "node-gyp-build-test": "build-test.js" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -14995,47 +14836,6 @@ "node": ">= 18" } }, - "node_modules/rpc-websockets": { - "version": "7.10.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/rpc-websockets/-/rpc-websockets-7.10.0.tgz", - "integrity": "sha512-cemZ6RiDtYZpPiBzYijdOrkQQzmBCmug0E9SdRH2gIUNT15ql4mwCYWIp0VnSZq6Qrw/JkGUygp4PrK1y9KfwQ==", - "license": "LGPL-3.0-only", - "dependencies": { - "@babel/runtime": "^7.17.2", - "eventemitter3": "^4.0.7", - "uuid": "^8.3.2", - "ws": "^8.5.0" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/kozjak" - }, - "optionalDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - } - }, - "node_modules/rpc-websockets/node_modules/ws": { - "version": "8.19.0", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/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/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -15291,12 +15091,6 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, - "node_modules/sdp": { - "version": "3.2.1", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/sdp/-/sdp-3.2.1.tgz", - "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==", - "license": "MIT" - }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -16503,19 +16297,6 @@ } } }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - } - }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", @@ -16748,70 +16529,6 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, - "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==", - "optional": true, - "peer": true, - "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/ts-node/node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ts-node/node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "optional": true, - "peer": true - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -17185,20 +16902,6 @@ "requires-port": "^1.0.0" } }, - "node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17239,13 +16942,6 @@ "uuid": "dist/bin/uuid" } }, - "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==", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -17790,19 +17486,6 @@ "node": ">=4.0" } }, - "node_modules/webrtc-adapter": { - "version": "9.0.3", - "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/webrtc-adapter/-/webrtc-adapter-9.0.3.tgz", - "integrity": "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==", - "license": "BSD-3-Clause", - "dependencies": { - "sdp": "^3.2.0" - }, - "engines": { - "node": ">=6.0.0", - "npm": ">=3.10.0" - } - }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", @@ -18424,16 +18107,6 @@ "node": ">=10" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c1abe4c..647fefd 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@types/node": "^16.18.126", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", - "bandwidth-rtc": "^0.2.0", + "bandwidth-rtc": "file:../javascript-brtc-sdk", "bandwidth-sdk": "^7.3.0", "cors": "^2.8.5", "dotenv": "^16.4.7", From 027419fbe6165262c71ce37f5d59b355555d1d89 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:50:06 -0400 Subject: [PATCH 04/16] fix: gate mediaStream path on callExpectedRef to prevent false in-call state The subscribe peer's WebRTC ontrack fires on initial connection before any call exists. callExpectedRef is set only when the WS streamAvailable notification arrives, ensuring the MediaStream and inCall state are only set when a real call is active. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1e75b6f..023f666 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import './css/App.scss'; import Navbar from "./components/Navbar"; import EndpointHandler from "./components/EndpointHandler"; @@ -15,6 +15,9 @@ function App() { const [inCall, setInCall] = useState(false); const [incomingCallId, setIncomingCallId] = useState(null); const [inboundStream, setInboundStream] = useState(null); + // true once the WS streamAvailable notification arrives; gates the WebRTC + // ontrack path so idle subscribe-peer tracks don't trigger "in call" state + const callExpectedRef = useRef(false); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -45,14 +48,16 @@ function App() { brtcClient.onStreamAvailable((s) => { console.log("Stream available:", s); if (s.callId && !s.mediaStream) { + callExpectedRef.current = true; setIncomingCallId(s.callId); - } else if (s.mediaStream) { + } else if (s.mediaStream && callExpectedRef.current) { setInCall(true); setInboundStream(s.mediaStream); } }); brtcClient.onStreamUnavailable((s) => { console.log("Stream unavailable:", s); + callExpectedRef.current = false; setInCall(false); setIncomingCallId(null); setInboundStream(null); From 5257017e19934e334cf2caadfabc640fac551b92 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:52:51 -0400 Subject: [PATCH 05/16] fix: store subscribe stream in ref so it's available when call notification arrives WebRTC ontrack fires once at connection setup, before the WS streamAvailable notification. Store the stream in subscribeStreamRef regardless of call state; wire it to inboundStream when the notification arrives (or on explicit accept) so the audio visualization always receives the stream. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 023f666..b0be692 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,9 +15,11 @@ function App() { const [inCall, setInCall] = useState(false); const [incomingCallId, setIncomingCallId] = useState(null); const [inboundStream, setInboundStream] = useState(null); - // true once the WS streamAvailable notification arrives; gates the WebRTC - // ontrack path so idle subscribe-peer tracks don't trigger "in call" state + // true once the WS streamAvailable notification arrives; gates in-call state const callExpectedRef = useRef(false); + // holds the subscribe-peer MediaStream from WebRTC ontrack, which fires once + // at connection time before any call arrives + const subscribeStreamRef = useRef(null); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -50,14 +52,23 @@ function App() { if (s.callId && !s.mediaStream) { callExpectedRef.current = true; setIncomingCallId(s.callId); - } else if (s.mediaStream && callExpectedRef.current) { - setInCall(true); - setInboundStream(s.mediaStream); + // ontrack may have already fired before this notification arrived + if (subscribeStreamRef.current) { + setInCall(true); + setInboundStream(subscribeStreamRef.current); + } + } else if (s.mediaStream) { + subscribeStreamRef.current = s.mediaStream; + if (callExpectedRef.current) { + setInCall(true); + setInboundStream(s.mediaStream); + } } }); brtcClient.onStreamUnavailable((s) => { console.log("Stream unavailable:", s); callExpectedRef.current = false; + subscribeStreamRef.current = null; setInCall(false); setIncomingCallId(null); setInboundStream(null); @@ -69,6 +80,9 @@ function App() { await brtcClient.acceptStream(incomingCallId ?? undefined); setInCall(true); setIncomingCallId(null); + if (subscribeStreamRef.current) { + setInboundStream(subscribeStreamRef.current); + } }; const handleDecline = async () => { From f5671ac8f03b007e1c53b7e64ac3a7b39a2ec649 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:58:29 -0400 Subject: [PATCH 06/16] fix: audio before accept and broken hangup - Remove setInboundStream from WS notification path; audio only wires up on explicit accept, preventing the stream from playing before the user answers - CallController.handleHangUp now calls setInCall(false) directly after hangupConnection resolves instead of waiting for streamUnavailable; the gateway also returns {status} not {result} so dropped the stale field read Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 5 ----- src/components/CallController.tsx | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b0be692..0a6e6ea 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -52,11 +52,6 @@ function App() { if (s.callId && !s.mediaStream) { callExpectedRef.current = true; setIncomingCallId(s.callId); - // ontrack may have already fired before this notification arrived - if (subscribeStreamRef.current) { - setInCall(true); - setInboundStream(subscribeStreamRef.current); - } } else if (s.mediaStream) { subscribeStreamRef.current = s.mediaStream; if (callExpectedRef.current) { diff --git a/src/components/CallController.tsx b/src/components/CallController.tsx index e8e97aa..1be5ed5 100644 --- a/src/components/CallController.tsx +++ b/src/components/CallController.tsx @@ -100,10 +100,10 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}: } const handleHangUp = async () => { setCallStatus('Hanging Up...'); - // Ensure E.164 format: clean digits and add '+' const e164Number = '+' + destNumber.replace(/[^\d]/g, ''); - let result = await bandwidthRtcClient.hangupConnection(e164Number, EndpointType.PHONE_NUMBER) - setCallStatus(result.result) + await bandwidthRtcClient.hangupConnection(e164Number, EndpointType.PHONE_NUMBER); + setInCall(false); + setCallStatus('Call ended'); } const handleDigitClick = (value: string) => { From 1ad3dc44f5fd5eb2bc835b455fdf856c8750a2f2 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:00:04 -0400 Subject: [PATCH 07/16] fix: clear inboundStream when inCall goes false hangupConnection set inCall=false but left inboundStream populated, so MediaPlayer kept rendering the remote audio visualization after call end. A useEffect on inCall ensures the stream is cleared for all exit paths. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 0a6e6ea..8d5f34a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -45,6 +45,12 @@ function App() { prepBrtcClient(false) }, []); + useEffect(() => { + if (!inCall) { + setInboundStream(null); + } + }, [inCall]); + useEffect(() => { if (!brtcClient) return; brtcClient.onStreamAvailable((s) => { From 86ecec068fc25ed619066fd9753821d2b030e050 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:04:04 -0400 Subject: [PATCH 08/16] fix: keep subscribeStreamRef across calls for consecutive call support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ontrack fires once per WS session lifetime — the subscribe peer's MediaStream is permanent. Clearing it on streamUnavailable left subscribeStreamRef null for the second call, so accept had no stream to wire up. callExpectedRef still gates when the stream is used. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/App.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 8d5f34a..18554d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -69,7 +69,9 @@ function App() { brtcClient.onStreamUnavailable((s) => { console.log("Stream unavailable:", s); callExpectedRef.current = false; - subscribeStreamRef.current = null; + // subscribeStreamRef is intentionally kept: the subscribe peer's + // MediaStream is permanent for the lifetime of the WS connection; + // ontrack only fires once so we need it for consecutive calls. setInCall(false); setIncomingCallId(null); setInboundStream(null); From 4555ba6e8902ce5404fc5d27c40aa6e97b9f8c3b Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:16:29 -0400 Subject: [PATCH 09/16] fix: clear audio element srcObject on stream teardown When inboundStream becomes null the audio element's srcObject was left pointing at the old MediaStream so the browser kept playing buffered audio from the ended call. Clear it explicitly in the cleanup path. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/MediaPlayer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index e6f8dec..1eb2638 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -33,6 +33,9 @@ function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { audioContext.resume(); setDirectMediaStream(destination.stream); } else if (!inboundStream && isSubscribed) { + if (audioRef.current) { + audioRef.current.srcObject = null; + } setIsSubscribed(false); setAudioSourceNode(null); setDirectMediaStream(null); From e34013934a73445f517bfecf65245cd84ae4c598 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 4 Jun 2026 17:14:04 -0400 Subject: [PATCH 10/16] feat: add auto-accept toggle and Connect callback URL --- package-lock.json | 59 ++++++++++++++++++++++++++++++ server/index.ts | 4 +- src/App.tsx | 17 ++++++++- src/components/EndpointHandler.tsx | 15 +++++++- 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6117613..254d741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2475,6 +2475,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "aix" @@ -2490,6 +2491,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "android" @@ -2505,6 +2507,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -2520,6 +2523,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "android" @@ -2535,6 +2539,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -2550,6 +2555,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -2565,6 +2571,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -2580,6 +2587,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -2595,6 +2603,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2610,6 +2619,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2625,6 +2635,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2640,6 +2651,7 @@ "cpu": [ "loong64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2655,6 +2667,7 @@ "cpu": [ "mips64el" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2670,6 +2683,7 @@ "cpu": [ "ppc64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2685,6 +2699,7 @@ "cpu": [ "riscv64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2700,6 +2715,7 @@ "cpu": [ "s390x" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2715,6 +2731,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -2730,6 +2747,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -2745,6 +2763,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "netbsd" @@ -2760,6 +2779,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -2775,6 +2795,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "openbsd" @@ -2790,6 +2811,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "openharmony" @@ -2805,6 +2827,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "sunos" @@ -2820,6 +2843,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2835,6 +2859,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -2850,6 +2875,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -3756,6 +3782,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, "hasInstallScript": true, "optional": true, "dependencies": { @@ -3794,6 +3821,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "android" @@ -3813,6 +3841,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -3832,6 +3861,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "darwin" @@ -3851,6 +3881,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "freebsd" @@ -3870,6 +3901,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3889,6 +3921,7 @@ "cpu": [ "arm" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3908,6 +3941,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3927,6 +3961,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3946,6 +3981,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3965,6 +4001,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "linux" @@ -3984,6 +4021,7 @@ "cpu": [ "arm64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -4003,6 +4041,7 @@ "cpu": [ "ia32" ], + "dev": true, "optional": true, "os": [ "win32" @@ -4022,6 +4061,7 @@ "cpu": [ "x64" ], + "dev": true, "optional": true, "os": [ "win32" @@ -7423,6 +7463,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "optional": true, "bin": { "detect-libc": "bin/detect-libc.js" @@ -12171,6 +12212,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "optional": true }, "node_modules/node-forge": { @@ -16297,6 +16339,23 @@ } } }, + "node_modules/tailwindcss/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://bandwidth.jfrog.io/artifactory/api/npm/npm/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/tapable": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", diff --git a/server/index.ts b/server/index.ts index b86d424..00d63c6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -184,7 +184,7 @@ function processInboundCall(callId: string): string { return ` Connecting - + ${endpointId} `; @@ -192,7 +192,7 @@ function processInboundCall(callId: string): string { return ` Connecting - + ${requestingEndpointId} `; diff --git a/src/App.tsx b/src/App.tsx index 18554d5..287f4c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,9 @@ function App() { const [inCall, setInCall] = useState(false); const [incomingCallId, setIncomingCallId] = useState(null); const [inboundStream, setInboundStream] = useState(null); + const [autoAccept, setAutoAccept] = useState(true); + const autoAcceptRef = useRef(autoAccept); + useEffect(() => { autoAcceptRef.current = autoAccept; }, [autoAccept]); // true once the WS streamAvailable notification arrives; gates in-call state const callExpectedRef = useRef(false); // holds the subscribe-peer MediaStream from WebRTC ontrack, which fires once @@ -57,7 +60,17 @@ function App() { console.log("Stream available:", s); if (s.callId && !s.mediaStream) { callExpectedRef.current = true; - setIncomingCallId(s.callId); + if (s.autoAccepted === true) { + // New SDK: server explicitly auto-accepted — inCall transitions when the MediaStream arrives below. + } else if (autoAcceptRef.current) { + brtcClient.acceptStream(s.callId); + setInCall(true); + if (subscribeStreamRef.current) { + setInboundStream(subscribeStreamRef.current); + } + } else { + setIncomingCallId(s.callId); + } } else if (s.mediaStream) { subscribeStreamRef.current = s.mediaStream; if (callExpectedRef.current) { @@ -105,7 +118,7 @@ function App() { {brtcClient && ( <> - +
{brtcClientReady} {incomingCallId && ( diff --git a/src/components/EndpointHandler.tsx b/src/components/EndpointHandler.tsx index bdfd09c..a08b06c 100644 --- a/src/components/EndpointHandler.tsx +++ b/src/components/EndpointHandler.tsx @@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react"; import BandwidthRtc from "bandwidth-rtc"; import {Endpoint} from "../../server/types" -function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string }) { +function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccept, setAutoAccept}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string, autoAccept: boolean, setAutoAccept: (v: boolean) => void }) { const [endpoint, setEndpoint] = useState(null); const [banner, setBanner] = useState<{ message: string; isError: boolean } | null>(null); @@ -22,7 +22,8 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwid await bandwidthRtcClient.connect({ endpointToken: endpointData.token }, { - websocketUrl: gatewayUrl + websocketUrl: gatewayUrl, + autoAccept }).then(() => { console.log("WebRTC Client Connected"); }).catch((error) => { @@ -82,6 +83,16 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwid
)} + From 5058e1923f87f1dbaa65bbd3fe2b7b2de770fcf1 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 9 Jun 2026 10:53:27 -0400 Subject: [PATCH 11/16] feat(MediaPlayer): wire BandwidthRtc stream events to component --- src/components/MediaPlayer.tsx | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 1eb2638..aa52721 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -1,4 +1,34 @@ import React, {useEffect, useRef, useState} from "react"; +import BandwidthRtc, {RtcStream} from "bandwidth-rtc"; + +function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClient: BandwidthRtc, inCall: boolean, setInCall: (inCall: boolean) => void} ) { + if (!bandwidthRtcClient) { + throw new Error("webrtcClient is required"); + } + if (!setInCall) { + throw new Error("setInCall is required"); + } + + useEffect(() => { + bandwidthRtcClient.onStreamAvailable(async (s) => { + console.log("Stream available:", s); + if (!s.mediaStream) { + console.warn("onStreamAvailable fired but rtcStream.mediaStream is null — skipping subscribe"); + return; + } + try { + await handleMediaSubscribe(s); + setInCall(true); + } catch (err) { + console.error("Failed to subscribe to media stream:", err); + } + }); + bandwidthRtcClient.onStreamUnavailable(async (s) => { + console.log("Stream unavailable:", s); + setInCall(false); + await handleMediaUnsubscribe(s); + }); + }, [bandwidthRtcClient]); function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { const audioRef = useRef(null); From f33258e6843a676b10374cae40e284b1ec017467 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 9 Jun 2026 11:26:48 -0400 Subject: [PATCH 12/16] refactor: simplify MediaPlayer and fix auto-accepted call handling --- src/App.tsx | 5 ++- src/components/MediaPlayer.tsx | 74 +--------------------------------- 2 files changed, 6 insertions(+), 73 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 287f4c2..ab99e53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,7 +61,10 @@ function App() { if (s.callId && !s.mediaStream) { callExpectedRef.current = true; if (s.autoAccepted === true) { - // New SDK: server explicitly auto-accepted — inCall transitions when the MediaStream arrives below. + setInCall(true); + if (subscribeStreamRef.current) { + setInboundStream(subscribeStreamRef.current); + } } else if (autoAcceptRef.current) { brtcClient.acceptStream(s.callId); setInCall(true); diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 4d901e7..257a855 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -1,34 +1,5 @@ import React, {useEffect, useRef, useState} from "react"; -import BandwidthRtc, {RtcStream} from "bandwidth-rtc"; -function MediaPlayer({bandwidthRtcClient, inCall, setInCall}: {bandwidthRtcClient: BandwidthRtc, inCall: boolean, setInCall: (inCall: boolean) => void} ) { - if (!bandwidthRtcClient) { - throw new Error("webrtcClient is required"); - } - if (!setInCall) { - throw new Error("setInCall is required"); - } - - useEffect(() => { - bandwidthRtcClient.onStreamAvailable(async (s) => { - console.log("Stream available:", s); - if (!s.mediaStream) { - console.warn("onStreamAvailable fired but rtcStream.mediaStream is null — skipping subscribe"); - return; - } - try { - await handleMediaSubscribe(s); - setInCall(true); - } catch (err) { - console.error("Failed to subscribe to media stream:", err); - } - }); - bandwidthRtcClient.onStreamUnavailable(async (s) => { - console.log("Stream unavailable:", s); - setInCall(false); - await handleMediaUnsubscribe(s); - }); - }, [bandwidthRtcClient]); function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { const audioRef = useRef(null); @@ -78,7 +49,7 @@ function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { let lineColor = "rgb(0 0 0)"; let drawFft = false; for (let i = 0; i < dataArray.length; i++) { - if (dataArray[i] != 128) { + if (dataArray[i] !== 128) { drawFft = true; break; } @@ -113,51 +84,10 @@ function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { requestAnimationFrame(drawFFT); }; - const handleMediaSubscribe = async (rtcStream: RtcStream): Promise => { - if (!audioContext || !analyser) { - throw new Error("Audio context or analyser is not initialized"); - } - if (!audioRef.current) { - throw new Error("Audio element is not initialized"); - } - if (!rtcStream.mediaStream) { - throw new Error("RTC stream has no media stream"); - } - if (!isSubscribed) { - let sourceNode = audioContext.createMediaStreamSource(rtcStream.mediaStream); - let destination = audioContext.createMediaStreamDestination(); - - sourceNode.connect(analyser); - analyser.connect(destination); - let stream = destination.stream; - drawFFT(); - audioRef.current.srcObject = rtcStream.mediaStream; - setAudioSourceNode(sourceNode); - setIsSubscribed(true); - await audioContext.resume() - setDirectMediaStream(stream) - return stream; - } else { - throw new Error("Already subscribed to media"); - } - } - - const handleMediaUnsubscribe = async (s: RtcStream) => { - console.log("Unsubscribing from stream:", s.mediaStream?.id); - // Stop playing if we are - if (isPlaying) { - await handlePlay() - } - if (isSubscribed) { - setIsSubscribed(false); - setAudioSourceNode(null); - setDirectMediaStream(null); - } - } - const handlePlay = async () => { const sourceNode = audioSourceNode; if (!sourceNode || !audioContext || !directMediaStream) return; + await audioContext.resume(); if (!localOutputAudioNode) { setLocalOutputAudioNode(sourceNode.connect(audioContext.destination)); setIsPlaying(true); From 54cbe20ed038d9f9f09ca8399dd906fb5f583a68 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 15 Jun 2026 16:08:20 -0400 Subject: [PATCH 13/16] feat: add BXML hangup endpoint and wire endpointId to CallController --- server/index.ts | 40 +++++++++++++++++++++++++++++- src/App.tsx | 5 ++-- src/components/CallController.tsx | 14 ++++++++++- src/components/EndpointHandler.tsx | 15 +++++------ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/server/index.ts b/server/index.ts index 6606feb..6a49638 100644 --- a/server/index.ts +++ b/server/index.ts @@ -438,6 +438,42 @@ app.post('/api/endpoint/:endpointId/hangup', async (req: Request, res: Response) } }); +// GET /bxml/hangup - Returns BXML that hangs up the call +app.get('/bxml/hangup', (req: Request, res: Response) => { + res.type('application/xml').send(` + + +`); +}); + +// POST /api/endpoint/:endpointId/bxml-hangup - Redirect active PSTN call to BXML Hangup +app.post('/api/endpoint/:endpointId/bxml-hangup', async (req: Request, res: Response) => { + const endpointId = req.params.endpointId; + const callStatus = endpointCallStatusMap.get(endpointId); + if (!callStatus) { + return res.status(404).json({ error: 'No active call for this endpoint' }); + } + + try { + const token = await getAuthToken(); + const configuration = new Configuration({ accessToken: token }); + if (VOICE_URL !== PROD_VOICE_URL) { + configuration.basePath = VOICE_URL; + } + + const callsApi = new CallsApi(configuration); + await callsApi.updateCall(ACCOUNT_ID, callStatus.callId, { + state: 'active', + redirectUrl: `${CALLBACK_BASE_URL}/bxml/hangup`, + }); + console.log(`Redirected call ${callStatus.callId} to BXML hangup for endpoint ${endpointId}`); + res.sendStatus(200); + } catch (error: any) { + console.error(`Error sending BXML hangup for endpoint ${endpointId}:`, error.message); + res.status(500).json({ error: error.message }); + } +}); + // DELETE /api/endpoints - Delete all endpoints on the account app.delete('/api/endpoints', async (req: Request, res: Response) => { try { @@ -497,7 +533,9 @@ app.listen(PORT, '0.0.0.0', () => { console.log(` GET /token - Create endpoint and get JWT`); console.log(` DELETE /api/endpoint/:endpointId - Delete endpoint`); console.log(` GET /api/endpoint/:endpointId/call-status - Get PSTN call status`); - console.log(` POST /api/endpoint/:endpointId/hangup - Hang up PSTN leg`); + console.log(` POST /api/endpoint/:endpointId/hangup - Hang up PSTN leg (Voice API)`); + console.log(` POST /api/endpoint/:endpointId/bxml-hangup - Hang up via BXML redirect`); + console.log(` GET /bxml/hangup - BXML Hangup verb`); console.log(` POST /callbacks/bandwidth - BRTC events + incoming PSTN calls`); console.log(` POST /callbacks/bandwidth/status - Voice API status (disconnect)`); console.log(` POST /calls/answer - Outbound call answer BXML callback`); diff --git a/src/App.tsx b/src/App.tsx index ab99e53..057123e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ function App() { // holds the subscribe-peer MediaStream from WebRTC ontrack, which fires once // at connection time before any call arrives const subscribeStreamRef = useRef(null); + const [endpointId, setEndpointId] = useState(null); const prepBrtcClient= async (reset: boolean) => { console.log("Prepping Bandwidth RTC Client") @@ -121,7 +122,7 @@ function App() { {brtcClient && ( <> - +
{brtcClientReady} {incomingCallId && ( @@ -162,7 +163,7 @@ function App() {
- +
)} diff --git a/src/components/CallController.tsx b/src/components/CallController.tsx index 1be5ed5..10cb042 100644 --- a/src/components/CallController.tsx +++ b/src/components/CallController.tsx @@ -70,7 +70,7 @@ function isDestinationValid(destination: string): boolean { return true } -function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}: {bandwidthRtcClient: BandwidthRtc, readyMetadata: ReadyMetadata, inCall: boolean, setInCall: (inCall: boolean) => void} ) { +function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall, endpointId}: {bandwidthRtcClient: BandwidthRtc, readyMetadata: ReadyMetadata, inCall: boolean, setInCall: (inCall: boolean) => void, endpointId?: string | null} ) { if (!bandwidthRtcClient) { throw new Error("webrtcClient is required"); } @@ -106,6 +106,11 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}: setCallStatus('Call ended'); } + const handleBxmlHangup = async () => { + if (!endpointId) return; + await fetch(`/api/endpoint/${endpointId}/bxml-hangup`, { method: 'POST' }); + }; + const handleDigitClick = (value: string) => { inCall ? bandwidthRtcClient.sendDtmf(value) : setDestNumber((destNumber) => destNumber.concat(value)); } @@ -191,6 +196,13 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
{!inCall ? : }
+ {inCall && ( +
+ +
+ )} ); diff --git a/src/components/EndpointHandler.tsx b/src/components/EndpointHandler.tsx index a08b06c..0b906a4 100644 --- a/src/components/EndpointHandler.tsx +++ b/src/components/EndpointHandler.tsx @@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react"; import BandwidthRtc from "bandwidth-rtc"; import {Endpoint} from "../../server/types" -function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccept, setAutoAccept}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string, autoAccept: boolean, setAutoAccept: (v: boolean) => void }) { +function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, onEndpointChange, autoAccept, setAutoAccept}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string, onEndpointChange?: (endpointId: string | null) => void, autoAccept?: boolean, setAutoAccept?: (v: boolean) => void }) { const [endpoint, setEndpoint] = useState(null); const [banner, setBanner] = useState<{ message: string; isError: boolean } | null>(null); @@ -19,6 +19,7 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccep throw new Error("Failed to create endpoint") } setEndpoint(endpointData) + onEndpointChange?.(endpointData.endpointId) await bandwidthRtcClient.connect({ endpointToken: endpointData.token }, { @@ -43,6 +44,7 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccep bandwidthRtcClient.disconnect(); resetClient(); setEndpoint(null); + onEndpointChange?.(null); setBanner({ message: 'Endpoints all deleted', isError: false }); setTimeout(() => setBanner(prev => prev && !prev.isError ? null : prev), 4000); } catch (err: any) { @@ -54,13 +56,8 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccep if (endpoint) { bandwidthRtcClient.disconnect(); resetClient(); // Reset for now until reconnection logic is fixed (init rebinding in bandwidthRtcClient.connect(...)) - let endpointData = await fetch(`/api/endpoint/${endpoint.endpointId}`, { method: "DELETE" }) - .then(res => res.json()) - .then(data => data as Endpoint) - .catch((err) => { - console.error(err); - return null; - }) as Endpoint | null; + onEndpointChange?.(null); + await fetch(`/api/endpoint/${endpoint.endpointId}`, { method: "DELETE" }).catch(console.error); setEndpoint(null) } } @@ -87,7 +84,7 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl, autoAccep setAutoAccept(e.target.checked)} + onChange={e => setAutoAccept?.(e.target.checked)} disabled={!!endpoint} style={{ marginRight: 4 }} /> From 91a43b9b04ea8936020118e8599835f800489c96 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 15 Jun 2026 16:15:22 -0400 Subject: [PATCH 14/16] fix: return early instead of throwing when RtcStream has no mediaStream The SDK may call onStreamAvailable twice when the new gateway SSRC mapping is in use: once from the signaling WS notification (no mediaStream) and once from the WebRTC ontrack event (with mediaStream). Guard against null mediaStream by returning early rather than throwing so the second call (which carries the real stream) can proceed normally. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/MediaPlayer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 257a855..93d7aab 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -84,6 +84,7 @@ function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { requestAnimationFrame(drawFFT); }; + const handlePlay = async () => { const sourceNode = audioSourceNode; if (!sourceNode || !audioContext || !directMediaStream) return; From c31f566195a22dd246642dcc8140b93cbc211d75 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 25 Jun 2026 16:32:07 -0400 Subject: [PATCH 15/16] fix: handle new SDK combined streamAvailable event (callId + mediaStream together) The updated SDK (metadata-for-stream-events) now fires onStreamAvailable once with both callId and mediaStream set, correlating the WS notification and WebRTC ontrack internally. The previous handler only covered two cases: WS-only (callId, no stream) and ontrack-only (stream, no callId), so the combined event fell through without setting inboundStream. Add an explicit combined-event branch that handles accept/decline/autoAccept the same way as the split paths, while keeping the old two-phase and ontrack-only branches for backwards compat with older SDK versions. Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 057123e..41299b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,7 +59,20 @@ function App() { if (!brtcClient) return; brtcClient.onStreamAvailable((s) => { console.log("Stream available:", s); - if (s.callId && !s.mediaStream) { + if (s.callId && s.mediaStream) { + // New SDK: combined event — callId and mediaStream arrive together. + if (s.autoAccepted === true || autoAcceptRef.current) { + if (s.autoAccepted !== true) { + brtcClient.acceptStream(s.callId); + } + setInCall(true); + setInboundStream(s.mediaStream); + } else { + subscribeStreamRef.current = s.mediaStream; + setIncomingCallId(s.callId); + } + } else if (s.callId && !s.mediaStream) { + // Old SDK two-phase: WS notification arrives before ontrack. callExpectedRef.current = true; if (s.autoAccepted === true) { setInCall(true); @@ -76,6 +89,7 @@ function App() { setIncomingCallId(s.callId); } } else if (s.mediaStream) { + // Old SDK: ontrack fires with stream (no callId). subscribeStreamRef.current = s.mediaStream; if (callExpectedRef.current) { setInCall(true); From 9f8b57eaa2d16b2df6ee3173d6079066c0b747e9 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 25 Jun 2026 16:56:43 -0400 Subject: [PATCH 16/16] refactor(MediaPlayer): migrate state to refs, simplify stream setup --- src/components/MediaPlayer.tsx | 123 +++++++++++++++------------------ 1 file changed, 55 insertions(+), 68 deletions(-) diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx index 93d7aab..a9ea170 100644 --- a/src/components/MediaPlayer.tsx +++ b/src/components/MediaPlayer.tsx @@ -1,76 +1,64 @@ -import React, {useEffect, useRef, useState} from "react"; +import React, { useEffect, useRef, useState } from "react"; - -function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { +function MediaPlayer({ inboundStream }: { inboundStream: MediaStream | null }) { const audioRef = useRef(null); const canvasRef = useRef(null); - const [audioSourceNode, setAudioSourceNode] = useState(null); - const [audioContext, setAudioContext] = useState(null); - const [analyser, setAnalyser] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [isSubscribed, setIsSubscribed] = useState(false); - const [dataArray] = useState(new Uint8Array(512)); - const [directMediaStream, setDirectMediaStream] = useState(null); - const [localOutputAudioNode, setLocalOutputAudioNode] = useState(undefined); + const contextRef = useRef(null); + const analyserRef = useRef(null); + const sourceRef = useRef(null); + const dataArrayRef = useRef(new Uint8Array(512)); + const speakersConnectedRef = useRef(false); + const [isPlaying, setIsPlaying] = useState(false); useEffect(() => { - const context = new window.AudioContext(); - const analyserNode = context.createAnalyser(); - analyserNode.fftSize = 512; - setAudioContext(context); - setAnalyser(analyserNode); + const context = new AudioContext(); + const analyser = context.createAnalyser(); + analyser.fftSize = 512; + contextRef.current = context; + analyserRef.current = analyser; }, []); useEffect(() => { - if (inboundStream && audioContext && analyser && audioRef.current && !isSubscribed) { - const sourceNode = audioContext.createMediaStreamSource(inboundStream); - const destination = audioContext.createMediaStreamDestination(); - sourceNode.connect(analyser); - analyser.connect(destination); - drawFFT(); - audioRef.current.srcObject = inboundStream; - setAudioSourceNode(sourceNode); - setIsSubscribed(true); - audioContext.resume(); - setDirectMediaStream(destination.stream); - } else if (!inboundStream && isSubscribed) { + const context = contextRef.current; + const analyser = analyserRef.current; + if (!inboundStream || !context || !analyser || !audioRef.current) { if (audioRef.current) { audioRef.current.srcObject = null; } - setIsSubscribed(false); - setAudioSourceNode(null); - setDirectMediaStream(null); + if (sourceRef.current) { + sourceRef.current.disconnect(); + sourceRef.current = null; + } + speakersConnectedRef.current = false; + setIsPlaying(false); + return; } - }, [inboundStream, audioContext, analyser]); + const source = context.createMediaStreamSource(inboundStream); + source.connect(analyser); + audioRef.current.srcObject = inboundStream; + sourceRef.current = source; + context.resume(); + drawFFT(); + }, [inboundStream]); const drawFFT = () => { - if (!analyser || !canvasRef.current) return; - let bgColor = "rgb(200 200 200)"; - let lineColor = "rgb(0 0 0)"; - let drawFft = false; - for (let i = 0; i < dataArray.length; i++) { - if (dataArray[i] !== 128) { - drawFft = true; - break; - } - } - if (!drawFft) { - bgColor = "rgb(200 200 200)"; - lineColor = "rgb(200 200 200)"; - } + const analyser = analyserRef.current; const canvas = canvasRef.current; + if (!analyser || !canvas) { return; } const ctx = canvas.getContext("2d"); - if (!ctx) return; - analyser.getByteTimeDomainData(dataArray); - ctx.fillStyle = bgColor; + if (!ctx) { return; } + const data = dataArrayRef.current; + analyser.getByteTimeDomainData(data); + const active = data.some(v => v !== 128); + ctx.fillStyle = "rgb(200 200 200)"; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.lineWidth = 2; - ctx.strokeStyle = lineColor; + ctx.strokeStyle = active ? "rgb(0 0 0)" : "rgb(200 200 200)"; ctx.beginPath(); - const sliceWidth = (ctx.canvas.width * 1.0) / dataArray.length; + const sliceWidth = ctx.canvas.width / data.length; let x = 0; - for (let i = 0; i < dataArray.length; i++) { - const v = dataArray[i] / 128.0; + for (let i = 0; i < data.length; i++) { + const v = data[i] / 128.0; const y = (v * ctx.canvas.height) / 2; if (i === 0) { ctx.moveTo(x, y); @@ -84,29 +72,28 @@ function MediaPlayer({inboundStream}: {inboundStream: MediaStream | null}) { requestAnimationFrame(drawFFT); }; - const handlePlay = async () => { - const sourceNode = audioSourceNode; - if (!sourceNode || !audioContext || !directMediaStream) return; - await audioContext.resume(); - if (!localOutputAudioNode) { - setLocalOutputAudioNode(sourceNode.connect(audioContext.destination)); - setIsPlaying(true); - } else { - sourceNode.disconnect(audioContext.destination); - setLocalOutputAudioNode(undefined); + const context = contextRef.current; + const source = sourceRef.current; + if (!context || !source) { return; } + await context.resume(); + if (speakersConnectedRef.current) { + source.disconnect(context.destination); + speakersConnectedRef.current = false; setIsPlaying(false); + } else { + source.connect(context.destination); + speakersConnectedRef.current = true; + setIsPlaying(true); } }; return (
+
);