For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • The three device kinds
  • Prompt for permission
  • Read the device lists
  • Apply the user’s choice
  • Route remote audio to the chosen speaker
  • React to device changes
  • Try it: enumerate and switch devices
  • Next steps
Build Voice & Video Apps

Device Management

Pick the mic, camera, and speaker — before a call and on the fly

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Screen Sharing

Next
Built with

To let users pick which microphone, camera, and speaker a call uses, subscribe to the device lists, render a picker, and apply the user’s choice — either as a preference for the next call or as a live swap during one. The same APIs cover hot-plug events, so a new headset shows up in the list as soon as it’s connected.

Browser
1import { SignalWire, StaticCredentialProvider } from "@signalwire/js";
2
3const client = new SignalWire(new StaticCredentialProvider({ token: SAT }));
4
5// 1. Prompt for permission so devices come back with real labels.
6await client.requestMediaPermissions({ audio: true, video: true });
7
8// 2. Render the lists; they re-emit when devices are plugged or unplugged.
9client.audioInputDevices$.subscribe((mics) => renderMicOptions(mics));
10client.videoInputDevices$.subscribe((cams) => renderCamOptions(cams));
11client.audioOutputDevices$.subscribe((speakers) => renderSpeakerOptions(speakers));
12
13// 3. Apply the user's choice. Before a call, set the preference on the client;
14// during a call, switch the live track on `call.self`.
15micSelect.onchange = () => {
16 const mic = client.audioInputDevices.find((d) => d.deviceId === micSelect.value);
17 if (activeCall?.self) activeCall.self.selectAudioInputDevice(mic);
18 else client.selectAudioInputDevice(mic);
19};

The sections below cover requesting permission, reading the device lists, applying the user’s pick before a call and again mid-call, routing remote audio to the chosen speaker, and reacting when a device disappears.

Before you start. Devices live behind the browser’s permission gate. getUserMedia only runs on secure origins (HTTPS or localhost), and device labels are empty strings until the user has granted access at least once. Plan the UX so picking a device comes after the prompt — not the other way around.

The three device kinds

Each kind comes with its own pair of observables: one for the list of available devices, and one for the current selection. Subscribe to both whenever you render a picker so the <select> reflects what’s actually in use — including changes that happen without an explicit user pick, such as a recovery after the active device was unplugged.

Microphones

audioInputDevices$ lists every mic; selectedAudioInputDevice$ tracks the current pick.

Cameras

videoInputDevices$ lists every camera; selectedVideoInputDevice$ tracks the current pick.

Speakers

audioOutputDevices$ lists every speaker; selectedAudioOutputDevice$ tracks the current pick.

When you only need the current snapshot — for example, populating a dropdown once on click — read the non-$ accessor instead: client.audioInputDevices, client.videoInputDevices, client.audioOutputDevices. Use the observable when the UI should keep up with hot-plugs and SDK-driven switches.

Prompt for permission

Each list populates from navigator.mediaDevices.enumerateDevices(). Until the user grants permission, that call returns devices with empty label strings, which makes a picker UI useless. Call requestMediaPermissions() once at startup to drive the prompt and re-enumerate with labels filled in:

Browser
1const result = await client.requestMediaPermissions({ audio: true, video: true });
2
3if (!result.audio || !result.video) {
4 showPermissionBanner("Allow microphone and camera in your browser to continue.");
5}

The returned PermissionResult reports which scopes were granted and which device the browser handed back for each kind. Those devices become the initial selection unless you’ve already set a preference. The browser only shows the prompt the first time per origin; later calls resolve immediately with the existing grant.

If the user denies the prompt, enumerateDevices() still returns the device list, but with empty labels. Show a “permission needed” banner instead of an empty dropdown, and link to the browser’s lock-icon controls so they can grant it later.

Read the device lists

Each list is an observable of MediaDeviceInfo[]. Subscribe once at startup — every subscription receives the current list immediately and then again whenever the OS reports a change. Pair the list with the matching selected…$ observable so a <select> reflects the right value when the SDK switches devices on its own:

Browser
1let mics = [];
2let selectedMic = null;
3
4function renderMics() {
5 micSelect.innerHTML = "";
6 for (const mic of mics) {
7 const option = new Option(mic.label || `Microphone ${mic.deviceId.slice(0, 6)}`, mic.deviceId);
8 option.selected = mic.deviceId === selectedMic?.deviceId;
9 micSelect.append(option);
10 }
11}
12
13client.audioInputDevices$.subscribe((list) => {
14 mics = list;
15 renderMics();
16});
17
18client.selectedAudioInputDevice$.subscribe((device) => {
19 selectedMic = device;
20 renderMics();
21});

The same pattern fits videoInputDevices$ and audioOutputDevices$ — only the property names change. Caching the latest of each list in module-scope state keeps the rendering pure and avoids pulling in observable combinators.

Apply the user’s choice

There are two scopes for “use this device.” Match the scope to the moment the user picks:

Before a call
During a call

Set a preference on the client. The selection sticks for every future dial() and answer(), so the next outbound call is captured from the right mic and camera without any extra wiring. Wire the picker’s change event to look up the chosen MediaDeviceInfo from the live list and pass it to selectAudioInputDevice() or selectVideoInputDevice():

Browser
1// <select id="mic-select"> populated from client.audioInputDevices$
2micSelect.onchange = () => {
3 const mic = client.audioInputDevices.find(
4 (d) => d.deviceId === micSelect.value,
5 );
6 // null clears the preference and falls back to the system default.
7 client.selectAudioInputDevice(mic ?? null);
8};
9
10camSelect.onchange = () => {
11 const camera = client.videoInputDevices.find(
12 (d) => d.deviceId === camSelect.value,
13 );
14 client.selectVideoInputDevice(camera ?? null);
15};
16
17// Subsequent dials use the preference automatically.
18const call = await client.dial("/private/alice", { audio: true, video: true });

The next dial() captures from the chosen mic and camera with no extra arguments. To change the default back later, look up a new device the same way, or pass null to clear the preference.

In practice, a single picker handler covers both scopes — fall through to the client-level preference when there’s no active call, replace the live track when there is:

Browser
1let activeCall = null;
2
3function pickMicrophone(): void {
4 const mic = client.audioInputDevices.find(
5 (d) => d.deviceId === micSelect.value,
6 );
7 if (!mic) return;
8
9 if (activeCall?.self) {
10 // Live swap; mid-call only.
11 activeCall.self.selectAudioInputDevice(mic, { savePreference: true });
12 } else {
13 // Preference for the next dial()/answer().
14 client.selectAudioInputDevice(mic);
15 }
16}
17
18micSelect.onchange = pickMicrophone;
19
20// Keep `activeCall` in sync with whatever call the user is on right now.
21client.dial("/private/alice").then((call) => {
22 activeCall = call;
23 call.status$.subscribe((s) => {
24 if (s === "destroyed") activeCall = null;
25 });
26});

Speakers follow a slightly different path — see the next section.

Route remote audio to the chosen speaker

Selecting a speaker takes an extra step the other devices don’t — most browsers route audio output through the <video> or <audio> element the remote stream is attached to, and switching the sink is an element-level call. applySelectedAudioOutputDevice() wraps HTMLMediaElement.setSinkId() so you don’t have to:

Browser
1client.selectAudioOutputDevice(speaker);
2const applied = await client.applySelectedAudioOutputDevice(remoteVideo);
3if (!applied) {
4 // Browser doesn't support setSinkId — fall through to the system default.
5}

The method returns true when the sink was changed and false when no speaker is selected or the browser doesn’t support setSinkId. Call it once after binding the remote stream to the element, and again any time the user picks a new speaker.

Firefox doesn’t ship setSinkId yet. applySelectedAudioOutputDevice() returns false on Firefox and audio plays through the system default speaker. Check the return value and surface a “speaker selection unavailable” notice rather than silently swallowing the choice — see the MDN compatibility table.

React to device changes

Hot-plug events are already covered by the list observables — adding a USB headset re-emits audioInputDevices$ with the new entry. What you usually want on top of that is a notification when the SDK automatically switches a device because the previous one was unplugged. Subscribe to deviceRecovered$:

Browser
1client.deviceRecovered$.subscribe((event) => {
2 toast(`${event.kind} switched to ${event.newDevice?.label ?? "system default"}`);
3});

The DeviceRecoveryEvent carries everything you need to compose that notification:

FieldWhat it tells you
kindWhich kind switched: audioinput, videoinput, or audiooutput
previousDeviceThe device that was active before the swap (may be null if it was unplugged)
newDeviceThe device the SDK switched to (may be null if it fell back to the system default)
reasonWhy: device_disconnected, device_reconnected, fallback_to_default, and a few more

Reach for it when the user should know “we lost your AirPods and put you on the built-in mic” rather than discovering it mid-sentence.

Device monitoring is on by default. Use disableDeviceMonitoring() and enableDeviceMonitoring() to pause and resume it — handy on mobile when the page goes into the background and you don’t want hot-plug events firing while the user can’t see the UI.

Try it: enumerate and switch devices

The fastest way to see the device APIs end-to-end is a single page that wires the three observable lists to three <select> elements, dials a test destination on demand, and switches the mic, camera, or speaker on the live call from the same picker. The handler routes preference changes through client.* before the call and live-track swaps through activeCall.self.* while the call is up — so the same dropdowns exercise both branches of the Apply the user’s choice section.

1

Issue a Subscriber Access Token

Create a SAT for your project — the Authentication guide covers the production version of this flow; the Create Subscriber Token reference sends the request for you.

POST
/api/fabric/subscribers/tokens
1curl -X POST https://{your_space_name}.signalwire.com/api/fabric/subscribers/tokens \
2 -H "Content-Type: application/json" \
3 -u "<project_id>:<api_token>" \
4 -d '{
5 "reference": "john.doe@example.com"
6}'
Try it
2

Open the demo and request permission

Save the page below as devices-demo.html and open it over HTTPS (or localhost). Paste the SAT and click Connect — the log reports the WebSocket coming up. Click Request permission to drive the browser’s prompt; the three dropdowns populate with labelled devices.

Pick a different mic, camera, or speaker before dialing. The log records each pick as preference: … — that’s the client.select* branch in action. Plug or unplug a USB headset to see deviceRecovered$ fire.

devices-demo.html — full source
1<!doctype html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <title>SignalWire SDK device-management demo</title>
6 <style>
7 /* Shared demo shell — identical across the inbound, outbound, and
8 device-management guides. Per-demo extras go below this block. */
9 body { font: 14px/1.5 system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
10 label { display: block; margin: 0.75rem 0 0.25rem; font-weight: 600; }
11 input, select { width: 100%; padding: 0.5rem; font: 13px ui-monospace, monospace; box-sizing: border-box; }
12 button { margin: 0.5rem 0.5rem 0 0; padding: 0.5rem 1rem; font: 14px system-ui; cursor: pointer; }
13 button[disabled] { opacity: 0.5; cursor: not-allowed; }
14 .videos { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-top: 1rem; }
15 video { width: 100%; background: #000; border-radius: 4px; aspect-ratio: 4/3; }
16 #log { margin-top: 1rem; padding: 1rem; background: #111; color: #0f0; font: 13px ui-monospace, monospace; min-height: 6rem; white-space: pre-wrap; border-radius: 4px; }
17 /* Device-management-specific */
18 .devices { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.5rem 1rem; margin-top: 1rem; }
19 .devices > div { display: flex; flex-direction: column; }
20 </style>
21 </head>
22 <body>
23 <h1>SignalWire SDK device-management demo</h1>
24
25 <label for="token">Subscriber Access Token</label>
26 <input id="token" type="password" placeholder="Paste your SAT here" />
27
28 <button id="connect">Connect</button>
29 <button id="perms" disabled>Request permission</button>
30
31 <div class="devices">
32 <div>
33 <label for="mic">Microphone</label>
34 <select id="mic"><option>(none)</option></select>
35 </div>
36 <div>
37 <label for="cam">Camera</label>
38 <select id="cam"><option>(none)</option></select>
39 </div>
40 <div>
41 <label for="speaker">Speaker</label>
42 <select id="speaker"><option>(none)</option></select>
43 </div>
44 </div>
45
46 <label for="destination">Destination</label>
47 <input id="destination" type="text" placeholder="/public/test-room" />
48
49 <button id="dial" disabled>Dial</button>
50 <button id="hangup" disabled>Hang up</button>
51
52 <div class="videos">
53 <video id="local" autoplay muted playsinline></video>
54 <video id="remote" autoplay playsinline></video>
55 </div>
56
57 <pre id="log"></pre>
58
59 <script type="module">
60 import { SignalWire, StaticCredentialProvider } from "https://esm.sh/@signalwire/js@dev";
61
62 const log = (msg) =>
63 (document.getElementById("log").textContent += msg + "\n");
64 const $ = (id) => document.getElementById(id);
65
66 let client = null;
67 let activeCall = null;
68
69 // Cache the latest list + selection per kind so each subscription can
70 // rerender the picker without pulling in observable combinators.
71 const state = {
72 mic: { list: [], selected: null },
73 cam: { list: [], selected: null },
74 speaker: { list: [], selected: null },
75 };
76
77 function populate(kind) {
78 const selectEl = $(kind);
79 const { list, selected } = state[kind];
80 selectEl.innerHTML = "";
81 for (const d of list) {
82 const opt = new Option(d.label || `Device ${d.deviceId.slice(0, 6)}`, d.deviceId);
83 if (selected && d.deviceId === selected.deviceId) opt.selected = true;
84 selectEl.append(opt);
85 }
86 if (!list.length) selectEl.innerHTML = "<option>(none)</option>";
87 }
88
89 $("connect").onclick = () => {
90 const token = $("token").value.trim();
91 if (!token) return log("Paste a token first.");
92
93 $("connect").disabled = true;
94 log("Connecting...");
95
96 client = new SignalWire(new StaticCredentialProvider({ token }));
97
98 client.errors$.subscribe((err) =>
99 log("Error: " + (err.name || "Error") + " — " + err.message),
100 );
101
102 client.audioInputDevices$.subscribe((list) => { state.mic.list = list; populate("mic"); });
103 client.selectedAudioInputDevice$.subscribe((d) => { state.mic.selected = d; populate("mic"); });
104 client.videoInputDevices$.subscribe((list) => { state.cam.list = list; populate("cam"); });
105 client.selectedVideoInputDevice$.subscribe((d) => { state.cam.selected = d; populate("cam"); });
106 client.audioOutputDevices$.subscribe((list) => { state.speaker.list = list; populate("speaker"); });
107 client.selectedAudioOutputDevice$.subscribe((d) => { state.speaker.selected = d; populate("speaker"); });
108
109 client.deviceRecovered$.subscribe((e) =>
110 log(`auto-switch: ${e.kind} (${e.reason}) → ${e.newDevice?.label ?? "system default"}`),
111 );
112
113 $("perms").disabled = false;
114 $("dial").disabled = false;
115 log("Connected. Click Request permission to populate labels.");
116 };
117
118 $("perms").onclick = async () => {
119 const result = await client.requestMediaPermissions({ audio: true, video: true });
120 log(`permission: audio=${result.audio} video=${result.video}`);
121 };
122
123 // Single picker handler used by all three <select> elements.
124 // Routes through call.self when a call is up, client.* otherwise —
125 // matches the combined handler from the "Apply the user's choice" section.
126 function pick(kind) {
127 const list =
128 kind === "mic" ? client.audioInputDevices :
129 kind === "cam" ? client.videoInputDevices :
130 client.audioOutputDevices;
131 const device = list.find((d) => d.deviceId === $(kind).value);
132 if (!device) return;
133
134 if (activeCall?.self && kind !== "speaker") {
135 if (kind === "mic") activeCall.self.selectAudioInputDevice(device, { savePreference: true });
136 if (kind === "cam") activeCall.self.selectVideoInputDevice(device, { savePreference: true });
137 log(`live: ${kind} → ${device.label}`);
138 } else if (kind === "speaker") {
139 client.selectAudioOutputDevice(device);
140 client.applySelectedAudioOutputDevice($("remote"));
141 log(`speaker → ${device.label}`);
142 } else {
143 if (kind === "mic") client.selectAudioInputDevice(device);
144 if (kind === "cam") client.selectVideoInputDevice(device);
145 log(`preference: ${kind} → ${device.label}`);
146 }
147 }
148
149 $("mic").onchange = () => pick("mic");
150 $("cam").onchange = () => pick("cam");
151 $("speaker").onchange = () => pick("speaker");
152
153 $("dial").onclick = async () => {
154 const to = $("destination").value.trim();
155 if (!to) return log("Enter a destination first.");
156 $("dial").disabled = true;
157 log("Dialing " + to + "...");
158 try {
159 activeCall = await client.dial(to, { audio: true, video: true });
160 $("hangup").disabled = false;
161
162 activeCall.localStream$.subscribe((s) => ($("local").srcObject = s));
163 activeCall.remoteStream$.subscribe(async (s) => {
164 $("remote").srcObject = s;
165 if (client.selectedAudioOutputDevice) {
166 await client.applySelectedAudioOutputDevice($("remote"));
167 }
168 });
169 activeCall.status$.subscribe((status) => {
170 log("Status: " + status);
171 if (status === "destroyed") {
172 activeCall = null;
173 $("dial").disabled = false;
174 $("hangup").disabled = true;
175 }
176 });
177 } catch (err) {
178 log("dial() rejected: " + (err.name || "Error") + " — " + err.message);
179 $("dial").disabled = false;
180 }
181 };
182
183 $("hangup").onclick = () => { if (activeCall) activeCall.hangup(); };
184 </script>
185 </body>
186</html>
3

Switch a device mid-call

Enter a destination in the Destination field (a /public/<resource>, /private/<user>, PSTN number, or SIP URI the token can reach — see Outbound Calls for the destination shapes) and click Dial. The local and remote tiles populate once the call connects.

With the call connected, change the Microphone or Camera dropdown again. The log now records the change as live: … — the picker is routing through activeCall.self.selectAudioInputDevice() instead of the client-level preference, and the remote side hears or sees the new device immediately without renegotiation. Picking a new Speaker triggers applySelectedAudioOutputDevice() on the remote <video> element so the sink swaps too.

Click Hang up and try the dropdowns again — the log goes back to preference: … because activeCall is null. That’s the combined handler from the Apply the user’s choice section, end to end.

Next steps

Troubleshooting

Black video, missing audio, denied permissions.

DeviceController reference

Every device-related property and method on the client.