Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@types/node": "^16.18.126",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"bandwidth-rtc": "^0.3.0",
"bandwidth-rtc": "^0.4.0",
"bandwidth-sdk": "^7.3.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
Expand Down
23 changes: 23 additions & 0 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ function processInboundCall(callId: string): string {
}
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<StartRecording multiChannel="true" recordingAvailableUrl="${CALLBACK_BASE_URL}/recordingAvailable"/>
<SpeakSentence voice="julie">Connecting</SpeakSentence>
<Connect eventCallbackUrl="${CALLBACK_BASE_URL}/connectstatus">
<Endpoint>${endpointId}</Endpoint>
Expand All @@ -191,6 +192,7 @@ function processInboundCall(callId: string): string {
}
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<StartRecording multiChannel="true" recordingAvailableUrl="${CALLBACK_BASE_URL}/recordingAvailable/b_answer"/>
<SpeakSentence voice="julie">Connecting</SpeakSentence>
<Connect eventCallbackUrl="${CALLBACK_BASE_URL}/connectstatus">
<Endpoint>${requestingEndpointId}</Endpoint>
Expand Down Expand Up @@ -335,6 +337,20 @@ app.post('/callbacks/bandwidth', async (req: Request, res: Response) => {
} catch (error: any) {
console.error('Error placing outbound call:', error.message);
}
return res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><Response/>`);
}
if (toType === 'ENDPOINT') {
// Endpoint-to-endpoint has no PSTN leg to place. We answer the
// request with BXML containing a <Connect> verb targeting the
// destination endpoint; the callback proxy executes it against the
// requesting endpoint's leg, bridging the two WebRTC endpoints.
console.log(`Bridging endpoints via <Connect>: ${event.from} -> ${to}`);
return res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect eventCallbackUrl="${CALLBACK_BASE_URL}/connectstatus">
<Endpoint>${to}</Endpoint>
</Connect>
</Response>`);
}
return res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><Response/>`);
}
Expand Down Expand Up @@ -400,6 +416,13 @@ app.post('/calls/status', async (req: Request, res: Response) => {
}
});

// POST /connectstatus - Connect bridge lifecycle events (PSTN <Connect> and
// endpoint-to-endpoint connect). Logged for observability; no BXML response needed.
app.post('/connectstatus', (req: Request, res: Response) => {
console.log('Connect status event:', JSON.stringify(req.body, null, 2));
res.sendStatus(200);
});

// GET /api/endpoint/:endpointId/call-status - Get current PSTN call status for an endpoint
app.get('/api/endpoint/:endpointId/call-status', (req: Request, res: Response) => {
const endpointId = req.params.endpointId;
Expand Down
28 changes: 24 additions & 4 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function App() {
const [brtcClientReady, setBrtcClientReady] = useState<boolean>(false);
const [readyMetadata, setReadyMetadata] = useState<ReadyMetadata | null>(null);
const [inCall, setInCall] = useState(false);
const [inboundStream, setInboundStream] = useState<MediaStream | null>(null);

const prepBrtcClient= async (reset: boolean) => {
console.log("Prepping Bandwidth RTC Client")
Expand All @@ -21,10 +22,29 @@ function App() {
setBrtcClientReady(false)
setReadyMetadata(null)
setBrtcClient(null)
setInCall(false)
setInboundStream(null)
}
if (!brtcClient || reset) {
console.log("Creating Bandwidth RTC Client")
let brtcClient = new BandwidthRtc('debug')
// Registered before connect() runs: the subscribing peer connection's
// ontrack fires once during initial connection setup, before
// readyMetadata (and therefore MediaPlayer) exists.
brtcClient.onStreamAvailable((s) => {
console.log("Stream available:", s);
setInboundStream(s.mediaStream);
// The stream arriving means the call actually connected (answered),
// so we're truly in-call now — not just ringing.
setInCall(true);
})
brtcClient.onStreamUnavailable((s) => {
console.log("Stream unavailable:", s);
setInboundStream(null);
// The far side (or gateway) ended the call; reset the UI out of
// the in-call/ringing state.
setInCall(false);
})
brtcClient.onReady((readyMetadata: ReadyMetadata) => {
console.log("Ready Metadata:", readyMetadata);
setBrtcClientReady(true)
Expand Down Expand Up @@ -56,12 +76,12 @@ function App() {
{readyMetadata && (
<>
<h2>Bandwidth RTC Agent Sample</h2>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
<div className="app-row">
<MediaCapture bandwidthRtcClient={brtcClient} />
<MediaPlayer bandwidthRtcClient={brtcClient} inCall={inCall} setInCall={setInCall} />
<MediaPlayer inboundStream={inboundStream} />
</div>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
<CallController bandwidthRtcClient={brtcClient} readyMetadata={readyMetadata} inCall={inCall} setInCall={setInCall} />
<div className="app-row">
<CallController bandwidthRtcClient={brtcClient} readyMetadata={readyMetadata} inCall={inCall} setInCall={setInCall} connected={inboundStream !== null} />
</div>
</>
)}
Expand Down
58 changes: 42 additions & 16 deletions src/components/CallController.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {ChangeEventHandler, useState} from "react";
import '../css/DigitGrid.scss';
import '../css/CallControlButton.scss';
import '../css/NumberInput.scss';
import '../css/CallController.scss';
import CallIcon from '@mui/icons-material/Call';
import CallEndIcon from '@mui/icons-material/CallEnd';
import ShortcutOutlinedIcon from '@mui/icons-material/ShortcutOutlined';
Expand Down Expand Up @@ -66,11 +67,7 @@ function NumberInput ({ onChange, value }: {onChange: ChangeEventHandler, value:
}


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, connected}: {bandwidthRtcClient: BandwidthRtc, readyMetadata: ReadyMetadata, inCall: boolean, setInCall: (inCall: boolean) => void, connected: boolean} ) {
if (!bandwidthRtcClient) {
throw new Error("webrtcClient is required");
}
Expand Down Expand Up @@ -106,27 +103,38 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
let result = await bandwidthRtcClient.requestOutboundConnection(e164Number, EndpointType.PHONE_NUMBER)
if (result.accepted) {
setCallStatus('Ringing');
setInCall(true);
} else {
setCallStatus('Declined');
}
}
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)
// Hang up whichever destination the active call is using. endpointType is
// frozen during a call (the selector is disabled), so it still reflects
// how this call was dialed.
let result;
if (endpointType === EndpointType.ENDPOINT) {
result = await bandwidthRtcClient.hangupConnection(endpointTarget, EndpointType.ENDPOINT)
} else {
// Ensure E.164 format: clean digits and add '+'
const e164Number = '+' + destNumber.replace(/[^\d]/g, '');
result = await bandwidthRtcClient.hangupConnection(e164Number, EndpointType.PHONE_NUMBER)
}
setCallStatus(result.result)
setInCall(false);
}

const handleDigitClick = (value: string) => {
if (inCall) {
if (connected) {
// DTMF only once the call is answered; during ringing, digits do nothing.
console.log(`Sending DTMF: ${value}, duration: ${dtmfDuration}ms`);
try {
bandwidthRtcClient.sendDtmf(value, undefined, dtmfDuration);
} catch (err) {
console.error('sendDtmf failed:', err);
}
} else {
} else if (!inCall) {
setDestNumber((destNumber) => destNumber.concat(value));
}
}
Expand All @@ -135,7 +143,14 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
}

const handleDialEndpoint = async () => {
await bandwidthRtcClient.requestOutboundConnection(endpointTarget, EndpointType.ENDPOINT)
setCallStatus('Calling...');
let result = await bandwidthRtcClient.requestOutboundConnection(endpointTarget, EndpointType.ENDPOINT)
if (result.accepted) {
setCallStatus('Ringing');
setInCall(true);
} else {
setCallStatus('Declined');
}
}

const handleDial = async () => {
Expand All @@ -147,6 +162,15 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
}


// A dial is allowed only when the destination for the selected type is filled
// in. CALL_ID is not implemented yet, so it can never be dialed.
const hasDestination =
endpointType === EndpointType.PHONE_NUMBER
? destNumber.replace(/[^\d]/g, '').length > 0
: endpointType === EndpointType.ENDPOINT
? endpointTarget.trim().length > 0
: false;

const endCallButtonProps = {
type: 'end-call',
onClick: handleHangUp,
Expand All @@ -157,7 +181,7 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
const startCallButtonProps = {
type: 'start-call',
onClick: handleDial,
disabled: !isDestinationValid(destNumber) || inCall,
disabled: !hasDestination || inCall,
Icon: CallIcon
};

Expand Down Expand Up @@ -188,11 +212,13 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
))}
</select>

<h2>{!inCall && callStatus}{inCall && "In Call"}</h2>
<h2>{!inCall && callStatus}{inCall && !connected && "Ringing"}{inCall && connected && "In Call"}</h2>

{endpointType == EndpointType.ENDPOINT && (
<>
<input type="text" disabled={inCall} onChange={(value) => setEndpointTarget(value.target.value)} />
{!inCall
? <input type="text" placeholder="Endpoint ID" value={endpointTarget} onChange={(value) => setEndpointTarget(value.target.value)} />
: <div className='dialed-number'>{endpointTarget}</div>}
</>
)}
{endpointType == EndpointType.CALL_ID && (
Expand All @@ -206,7 +232,7 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
<div className='call-controls'>
<CallControlButton {...backspaceButtonProps} />
</div>
{inCall && (
{connected && (
<div className='dtmf-duration-control'>
<label htmlFor='dtmf-duration'>Tone Duration (ms)</label>
<input
Expand All @@ -219,7 +245,7 @@ function CallController({bandwidthRtcClient, readyMetadata, inCall, setInCall}:
/>
</div>
)}
{inCall && (
{connected && (
<div className='dtmf-sequence-control'>
<input
type='text'
Expand Down
11 changes: 7 additions & 4 deletions src/components/EndpointHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useEffect, useState} from "react";
import BandwidthRtc from "bandwidth-rtc";
import {Endpoint} from "../../server/types"
import '../css/EndpointHandler.scss';

function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwidthRtcClient: BandwidthRtc, resetClient: () => void, gatewayUrl?: string }) {
const [endpoint, setEndpoint] = useState<Endpoint | null>(null);
Expand Down Expand Up @@ -82,11 +83,13 @@ function EndpointHandler({bandwidthRtcClient, resetClient, gatewayUrl}: {bandwid
<button onClick={() => setBanner(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontWeight: 'bold', color: '#721c24' }}>✕</button>
</div>
)}
<button onClick={createEndpoint}>Create Endpoint</button>
<button onClick={deleteEndpoint} disabled={!endpoint}>Disconnect & Delete Endpoint</button>
<button onClick={deleteAllEndpoints}>Delete All Endpoints</button>
<div className="endpoint-actions">
<button onClick={createEndpoint}>Create Endpoint</button>
<button onClick={deleteEndpoint} disabled={!endpoint}>Disconnect & Delete Endpoint</button>
<button onClick={deleteAllEndpoints}>Delete All Endpoints</button>
</div>
{endpoint !== null && (
<p>{endpoint.endpointId}</p>
<p className="endpoint-id">{endpoint.endpointId}</p>
)}
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/MediaCapture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,10 @@ function MediaCapture({bandwidthRtcClient}: {bandwidthRtcClient: BandwidthRtc} )
};

return (
<div>
<div style={{ flex: '1 1 320px', minWidth: 0, maxWidth: 400 }}>
<div>
<audio ref={audioRef} />
<canvas ref={canvasRef} width={400} height={200} style={{ border: "1px solid black" }} />
<canvas ref={canvasRef} width={400} height={200} className="audio-canvas" style={{ border: "1px solid black" }} />
<div>
<select
value={selectedAudioInputDeviceId}
Expand Down
Loading