Spaces:
Running
Running
feat: bidirectional audio
Browse files- index.html +93 -2
index.html
CHANGED
|
@@ -520,6 +520,22 @@
|
|
| 520 |
</svg>
|
| 521 |
<span id="muteText">Unmute</span>
|
| 522 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
</div>
|
| 524 |
</div>
|
| 525 |
</div>
|
|
@@ -619,6 +635,10 @@
|
|
| 619 |
// Audio mute state (for robot's audio playback)
|
| 620 |
let isMuted = true; // Default to muted
|
| 621 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 622 |
// Export functions
|
| 623 |
window.loginToHuggingFace = loginToHuggingFace;
|
| 624 |
window.logout = logout;
|
|
@@ -628,6 +648,7 @@
|
|
| 628 |
window.playSound = playSound;
|
| 629 |
window.playSoundPreset = playSoundPreset;
|
| 630 |
window.toggleMute = toggleMute;
|
|
|
|
| 631 |
|
| 632 |
document.addEventListener('DOMContentLoaded', () => {
|
| 633 |
initAuth();
|
|
@@ -814,10 +835,28 @@
|
|
| 814 |
isMuted = true;
|
| 815 |
updateMuteButton();
|
| 816 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 817 |
peerConnection = new RTCPeerConnection({
|
| 818 |
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
| 819 |
});
|
| 820 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
peerConnection.ontrack = (e) => {
|
| 822 |
if (e.track.kind === 'video') {
|
| 823 |
const video = document.getElementById('remoteVideo');
|
|
@@ -868,8 +907,13 @@
|
|
| 868 |
if (!peerConnection) return;
|
| 869 |
try {
|
| 870 |
if (msg.sdp) {
|
| 871 |
-
|
| 872 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 873 |
const answer = await peerConnection.createAnswer();
|
| 874 |
await peerConnection.setLocalDescription(answer);
|
| 875 |
await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
|
|
@@ -913,6 +957,13 @@
|
|
| 913 |
await sendToServer({ type: 'endSession', sessionId: currentSessionId });
|
| 914 |
}
|
| 915 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 916 |
if (peerConnection) peerConnection.close();
|
| 917 |
if (dataChannel) dataChannel.close();
|
| 918 |
|
|
@@ -930,6 +981,7 @@
|
|
| 930 |
function enableControls(enabled) {
|
| 931 |
document.getElementById('btnPlaySound').disabled = !enabled;
|
| 932 |
document.getElementById('muteBtn').disabled = !enabled;
|
|
|
|
| 933 |
}
|
| 934 |
|
| 935 |
function toggleMute() {
|
|
@@ -957,6 +1009,45 @@
|
|
| 957 |
}
|
| 958 |
}
|
| 959 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
// ===================== Robot State =====================
|
| 961 |
function handleRobotMessage(data) {
|
| 962 |
if (data.state) updateStateDisplay(data.state);
|
|
|
|
| 520 |
</svg>
|
| 521 |
<span id="muteText">Unmute</span>
|
| 522 |
</button>
|
| 523 |
+
<button class="btn btn-mute muted" id="micBtn" onclick="toggleMic()" disabled>
|
| 524 |
+
<svg id="micOffIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 525 |
+
<line x1="1" y1="1" x2="23" y2="23"></line>
|
| 526 |
+
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
| 527 |
+
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2c0 .76-.13 1.49-.35 2.17"></path>
|
| 528 |
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
| 529 |
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
| 530 |
+
</svg>
|
| 531 |
+
<svg id="micOnIcon" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 532 |
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
| 533 |
+
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
| 534 |
+
<line x1="12" y1="19" x2="12" y2="23"></line>
|
| 535 |
+
<line x1="8" y1="23" x2="16" y2="23"></line>
|
| 536 |
+
</svg>
|
| 537 |
+
<span id="micText">Mic Off</span>
|
| 538 |
+
</button>
|
| 539 |
</div>
|
| 540 |
</div>
|
| 541 |
</div>
|
|
|
|
| 635 |
// Audio mute state (for robot's audio playback)
|
| 636 |
let isMuted = true; // Default to muted
|
| 637 |
|
| 638 |
+
// Microphone state (for speaking through the robot)
|
| 639 |
+
let localMicStream = null;
|
| 640 |
+
let isMicMuted = true; // Default mic off
|
| 641 |
+
|
| 642 |
// Export functions
|
| 643 |
window.loginToHuggingFace = loginToHuggingFace;
|
| 644 |
window.logout = logout;
|
|
|
|
| 648 |
window.playSound = playSound;
|
| 649 |
window.playSoundPreset = playSoundPreset;
|
| 650 |
window.toggleMute = toggleMute;
|
| 651 |
+
window.toggleMic = toggleMic;
|
| 652 |
|
| 653 |
document.addEventListener('DOMContentLoaded', () => {
|
| 654 |
initAuth();
|
|
|
|
| 835 |
isMuted = true;
|
| 836 |
updateMuteButton();
|
| 837 |
|
| 838 |
+
// Capture microphone for bidirectional audio (speak through robot)
|
| 839 |
+
try {
|
| 840 |
+
localMicStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 841 |
+
localMicStream.getAudioTracks().forEach(t => t.enabled = false); // Start muted
|
| 842 |
+
isMicMuted = true;
|
| 843 |
+
updateMicButton();
|
| 844 |
+
} catch (e) {
|
| 845 |
+
console.warn('Microphone not available, speak-through-robot disabled:', e);
|
| 846 |
+
localMicStream = null;
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
peerConnection = new RTCPeerConnection({
|
| 850 |
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
| 851 |
});
|
| 852 |
|
| 853 |
+
// Add mic track to PeerConnection (before SDP exchange)
|
| 854 |
+
if (localMicStream) {
|
| 855 |
+
for (const track of localMicStream.getAudioTracks()) {
|
| 856 |
+
peerConnection.addTrack(track, localMicStream);
|
| 857 |
+
}
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
peerConnection.ontrack = (e) => {
|
| 861 |
if (e.track.kind === 'video') {
|
| 862 |
const video = document.getElementById('remoteVideo');
|
|
|
|
| 907 |
if (!peerConnection) return;
|
| 908 |
try {
|
| 909 |
if (msg.sdp) {
|
| 910 |
+
let sdp = msg.sdp;
|
| 911 |
+
// If we have a mic, ensure audio is sendrecv for bidirectional audio
|
| 912 |
+
if (sdp.type === 'offer' && localMicStream) {
|
| 913 |
+
sdp = { ...sdp, sdp: makeAudioBidirectional(sdp.sdp) };
|
| 914 |
+
}
|
| 915 |
+
await peerConnection.setRemoteDescription(new RTCSessionDescription(sdp));
|
| 916 |
+
if (sdp.type === 'offer') {
|
| 917 |
const answer = await peerConnection.createAnswer();
|
| 918 |
await peerConnection.setLocalDescription(answer);
|
| 919 |
await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
|
|
|
|
| 957 |
await sendToServer({ type: 'endSession', sessionId: currentSessionId });
|
| 958 |
}
|
| 959 |
|
| 960 |
+
// Stop microphone
|
| 961 |
+
if (localMicStream) {
|
| 962 |
+
localMicStream.getTracks().forEach(t => t.stop());
|
| 963 |
+
localMicStream = null;
|
| 964 |
+
}
|
| 965 |
+
isMicMuted = true;
|
| 966 |
+
|
| 967 |
if (peerConnection) peerConnection.close();
|
| 968 |
if (dataChannel) dataChannel.close();
|
| 969 |
|
|
|
|
| 981 |
function enableControls(enabled) {
|
| 982 |
document.getElementById('btnPlaySound').disabled = !enabled;
|
| 983 |
document.getElementById('muteBtn').disabled = !enabled;
|
| 984 |
+
document.getElementById('micBtn').disabled = !enabled || !localMicStream;
|
| 985 |
}
|
| 986 |
|
| 987 |
function toggleMute() {
|
|
|
|
| 1009 |
}
|
| 1010 |
}
|
| 1011 |
|
| 1012 |
+
function toggleMic() {
|
| 1013 |
+
if (!localMicStream) return;
|
| 1014 |
+
isMicMuted = !isMicMuted;
|
| 1015 |
+
localMicStream.getAudioTracks().forEach(t => t.enabled = !isMicMuted);
|
| 1016 |
+
updateMicButton();
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
function updateMicButton() {
|
| 1020 |
+
const btn = document.getElementById('micBtn');
|
| 1021 |
+
const micOffIcon = document.getElementById('micOffIcon');
|
| 1022 |
+
const micOnIcon = document.getElementById('micOnIcon');
|
| 1023 |
+
const micText = document.getElementById('micText');
|
| 1024 |
+
|
| 1025 |
+
if (isMicMuted) {
|
| 1026 |
+
btn.classList.add('muted');
|
| 1027 |
+
micOffIcon.classList.remove('hidden');
|
| 1028 |
+
micOnIcon.classList.add('hidden');
|
| 1029 |
+
micText.textContent = 'Mic Off';
|
| 1030 |
+
} else {
|
| 1031 |
+
btn.classList.remove('muted');
|
| 1032 |
+
micOffIcon.classList.add('hidden');
|
| 1033 |
+
micOnIcon.classList.remove('hidden');
|
| 1034 |
+
micText.textContent = 'Mic On';
|
| 1035 |
+
}
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
function makeAudioBidirectional(sdp) {
|
| 1039 |
+
// Change audio section direction from sendonly to sendrecv
|
| 1040 |
+
// so the browser can send microphone audio to the robot
|
| 1041 |
+
const lines = sdp.split('\r\n');
|
| 1042 |
+
let inAudioSection = false;
|
| 1043 |
+
return lines.map(line => {
|
| 1044 |
+
if (line.startsWith('m=audio')) inAudioSection = true;
|
| 1045 |
+
else if (line.startsWith('m=')) inAudioSection = false;
|
| 1046 |
+
if (inAudioSection && line === 'a=sendonly') return 'a=sendrecv';
|
| 1047 |
+
return line;
|
| 1048 |
+
}).join('\r\n');
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
// ===================== Robot State =====================
|
| 1052 |
function handleRobotMessage(data) {
|
| 1053 |
if (data.state) updateStateDisplay(data.state);
|