Skip to content

Commit 268ee75

Browse files
feat(audio): add speaker selection with test button
1 parent 75a4362 commit 268ee75

7 files changed

Lines changed: 177 additions & 44 deletions

File tree

‎.env.template‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ====================================================
2-
# MiroTalk C2C v.1.3.09 - Environment Configuration
2+
# MiroTalk C2C v.1.3.10 - Environment Configuration
33
# ====================================================
44

55
# App environment

‎backend/server.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @license For private project or commercial purposes contact us at: license.mirotalk@gmail.com or purchase it directly via Code Canyon:
1010
* @license https://codecanyon.net/item/mirotalk-c2c-webrtc-real-time-cam-2-cam-video-conferences-and-screen-sharing/43383005
1111
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
12-
* @version 1.3.09
12+
* @version 1.3.10
1313
*/
1414

1515
require('dotenv').config();

‎frontend/css/client.css‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,34 @@ video::-webkit-media-controls {
754754
box-shadow: 0 0 0 3px rgba(55, 109, 249, 0.15);
755755
}
756756

757+
/* Test speaker button */
758+
.settings-test-btn {
759+
display: inline-flex;
760+
align-items: center;
761+
gap: 8px;
762+
margin-top: 8px;
763+
padding: 8px 14px;
764+
border: 1px solid rgba(255, 255, 255, 0.1);
765+
border-radius: 8px;
766+
background-color: rgba(0, 0, 0, 0.3);
767+
color: var(--text-color);
768+
font-size: 0.82rem;
769+
font-weight: 500;
770+
cursor: pointer;
771+
transition:
772+
background-color 0.2s ease,
773+
border-color 0.2s ease;
774+
}
775+
776+
.settings-test-btn:hover {
777+
background-color: rgba(255, 255, 255, 0.08);
778+
border-color: var(--accent-color);
779+
}
780+
781+
.settings-test-btn:active {
782+
transform: scale(0.97);
783+
}
784+
757785
/* Toggle rows */
758786
.settings-toggle-row {
759787
display: flex;

‎frontend/html/client.html‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ <h1 class="pulsate">Loading</h1>
157157
<label for="audioSource">Microphone</label>
158158
<select id="audioSource"></select>
159159
</div>
160+
<div id="audioOutputDiv" class="settings-field">
161+
<label for="audioOutputSource">Speaker</label>
162+
<select id="audioOutputSource"></select>
163+
<button id="testSpeakerBtn" class="settings-test-btn" type="button">
164+
<i class="fas fa-volume-high"></i> Test speaker
165+
</button>
166+
</div>
160167
</div>
161168

162169
<div class="settings-section">

‎frontend/js/client.js‎

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* @license For private project or commercial purposes contact us at: license.mirotalk@gmail.com or purchase it directly via Code Canyon:
1010
* @license https://codecanyon.net/item/mirotalk-c2c-webrtc-real-time-cam-2-cam-video-conferences-and-screen-sharing/43383005
1111
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
12-
* @version 1.3.09
12+
* @version 1.3.10
1313
*/
1414

1515
const roomId = new URLSearchParams(window.location.search).get('room');
@@ -37,6 +37,9 @@ const leaveBtn = document.getElementById('leaveBtn');
3737
const settings = document.getElementById('settings');
3838
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
3939
const audioSource = document.getElementById('audioSource');
40+
const audioOutputDiv = document.getElementById('audioOutputDiv');
41+
const audioOutputSource = document.getElementById('audioOutputSource');
42+
const testSpeakerBtn = document.getElementById('testSpeakerBtn');
4043
const videoSource = document.getElementById('videoSource');
4144
const videoQualitySelect = document.getElementById('videoQualitySelect');
4245
const videoFpsDiv = document.getElementById('videoFpsDiv');
@@ -635,10 +638,15 @@ async function enumerateDevices() {
635638
const devices = await navigator.mediaDevices.enumerateDevices();
636639
const videoDevices = devices.filter((device) => device.kind === 'videoinput' && device.deviceId !== 'default');
637640
const audioDevices = devices.filter((device) => device.kind === 'audioinput' && device.deviceId !== 'default');
641+
const speakerDevices = devices.filter(
642+
(device) => device.kind === 'audiooutput' && device.deviceId !== 'default'
643+
);
638644
removeAllChildren(audioSource);
639645
removeAllChildren(videoSource);
646+
removeAllChildren(audioOutputSource);
640647
LS.DEVICES_COUNT.audio = 0;
641648
LS.DEVICES_COUNT.video = 0;
649+
LS.DEVICES_COUNT.speaker = 0;
642650
for (const device of audioDevices) {
643651
await addChild(audioSource, device);
644652
LS.DEVICES_COUNT.audio++;
@@ -647,6 +655,16 @@ async function enumerateDevices() {
647655
await addChild(videoSource, device);
648656
LS.DEVICES_COUNT.video++;
649657
}
658+
// Speaker selection is only available where setSinkId is supported
659+
if (isAudioOutputSupported()) {
660+
for (const device of speakerDevices) {
661+
await addChild(audioOutputSource, device);
662+
LS.DEVICES_COUNT.speaker++;
663+
}
664+
elemDisplay(audioOutputDiv, LS.DEVICES_COUNT.speaker > 0);
665+
} else {
666+
elemDisplay(audioOutputDiv, false);
667+
}
650668
} catch (err) {
651669
playSound('error');
652670
console.error('[Error] enumerate devices audio/video', err);
@@ -670,6 +688,7 @@ function createVideoElement(id, stream, muted = false) {
670688
video.muted = muted;
671689
video.volume = muted ? 0 : 1;
672690
attachMediaStream(video, stream);
691+
if (!muted) applySinkId(video, getSelectedSpeakerId());
673692
video.play().catch(() => {});
674693
return video;
675694
}
@@ -690,6 +709,7 @@ function createAudioElement(id, stream, muted = false) {
690709
audio.muted = muted;
691710
audio.volume = muted ? 0 : 1;
692711
attachMediaStream(audio, stream);
712+
if (!muted) applySinkId(audio, getSelectedSpeakerId());
693713
audio.play().catch(() => {});
694714
return audio;
695715
}
@@ -1101,6 +1121,14 @@ function handleEvents() {
11011121
saveLocalStorageConfig();
11021122
changeMicrophone(e.target.value);
11031123
};
1124+
audioOutputSource.onchange = (e) => {
1125+
setLocalStorageSpeakerConfig(e.target);
1126+
saveLocalStorageConfig();
1127+
changeSpeaker(e.target.value);
1128+
};
1129+
testSpeakerBtn.onclick = () => {
1130+
playTestSound();
1131+
};
11041132
videoSource.onchange = (e) => {
11051133
resetVideoConstraints();
11061134
setLocalStorageCameraConfig(e.target);
@@ -1421,6 +1449,44 @@ function getAudioConstraints(deviceId = null) {
14211449
return audioConstraints;
14221450
}
14231451

1452+
function isAudioOutputSupported() {
1453+
return typeof HTMLMediaElement !== 'undefined' && 'setSinkId' in HTMLMediaElement.prototype;
1454+
}
1455+
1456+
function getSelectedSpeakerId() {
1457+
return localStorageConfig?.speaker?.devices?.select?.id || null;
1458+
}
1459+
1460+
async function applySinkId(element, sinkId) {
1461+
if (!element || !isAudioOutputSupported() || typeof element.setSinkId !== 'function') return;
1462+
if (!sinkId) return;
1463+
try {
1464+
await element.setSinkId(sinkId);
1465+
} catch (err) {
1466+
console.error('[Error] applySinkId', err);
1467+
}
1468+
}
1469+
1470+
function changeSpeaker(sinkId = false) {
1471+
if (!sinkId || !isAudioOutputSupported()) return;
1472+
const mediaElements = document.querySelectorAll('audio, video');
1473+
mediaElements.forEach((element) => applySinkId(element, sinkId));
1474+
console.log('Speaker (audio output) changed to', sinkId);
1475+
}
1476+
1477+
async function playTestSound() {
1478+
const audioToPlay = new Audio('../sounds/join.mp3');
1479+
audioToPlay.volume = 0.5;
1480+
const sinkId = getSelectedSpeakerId();
1481+
if (sinkId) await applySinkId(audioToPlay, sinkId);
1482+
try {
1483+
await audioToPlay.play();
1484+
} catch (err) {
1485+
console.error('[Error] playTestSound', err);
1486+
popupMessage('toast', 'Test speaker', 'Unable to play test sound', 'top');
1487+
}
1488+
}
1489+
14241490
function getVideoConstraints(deviceId = false) {
14251491
let videoConstraints = true;
14261492

@@ -1513,9 +1579,16 @@ function setLocalStorageAudioConfig(target) {
15131579
localStorageConfig.audio.devices.select.id = target.value;
15141580
}
15151581

1582+
function setLocalStorageSpeakerConfig(target) {
1583+
localStorageConfig.speaker.devices.select.index = target.selectedIndex;
1584+
localStorageConfig.speaker.devices.select.label = target.options[target.selectedIndex].text;
1585+
localStorageConfig.speaker.devices.select.id = target.value;
1586+
}
1587+
15161588
function saveLocalStorageConfig() {
15171589
localStorageConfig.video.devices.count = LS.DEVICES_COUNT.video;
15181590
localStorageConfig.audio.devices.count = LS.DEVICES_COUNT.audio;
1591+
localStorageConfig.speaker.devices.count = LS.DEVICES_COUNT.speaker;
15191592
LS.setConfig(localStorageConfig);
15201593
}
15211594

@@ -1524,6 +1597,9 @@ function loadLocalStorageConfig() {
15241597
videoQualitySelect.selectedIndex = localStorageConfig.video.settings.quality_index;
15251598
videoFpsSelect.selectedIndex = localStorageConfig.video.settings.fps_index;
15261599
audioSource.selectedIndex = localStorageConfig.audio.devices.select.index;
1600+
if (isAudioOutputSupported() && audioOutputSource.options.length > 0) {
1601+
audioOutputSource.selectedIndex = localStorageConfig?.speaker?.devices?.select?.index ?? 0;
1602+
}
15271603
switchNoiseSuppression.checked = localStorageConfig?.audio?.settings?.noise_suppression ?? false;
15281604
switchMaxVideoQuality.checked = localStorageConfig.video.settings.best_quality;
15291605
switchKeepAspectRatio.checked = localStorageConfig.video.settings.aspect_ratio;

‎package-lock.json‎

Lines changed: 61 additions & 39 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)