some tweaks
This commit is contained in:
1 parent
d4332601b6
commit
75c8350573
3 files changed
+116
-3
No files matched your search
+105
-3
@@ -9,6 +9,7 @@ const SPATIAL_SCALE = 0.12; // room units -> audio world units
|
||||
const FALLBACK_ICE = { iceServers: [{ urls: ['stun:turn.k6n.net:3478', 'stun:stun.l.google.com:19302'] }] };
|
||||
const LS_NAME = 'voicepoc.nick';
|
||||
const LS_ICE = 'voicepoc.iceOverride';
|
||||
const LS_AUDIO_INPUT = 'voicepoc.audioInput';
|
||||
|
||||
const S = {
|
||||
ws: null,
|
||||
@@ -69,6 +70,46 @@ function toast(msg, kind = '') {
|
||||
setTimeout(() => t.remove(), 5000);
|
||||
}
|
||||
|
||||
function playRetroCue(name) {
|
||||
const ctx = S.audioCtx;
|
||||
if (!ctx) return;
|
||||
ctx.resume().catch(() => {});
|
||||
|
||||
const cues = {
|
||||
join: [
|
||||
{ frequency: 659, offset: 0, duration: 0.09, type: 'square' },
|
||||
{ frequency: 988, offset: 0.08, duration: 0.16, type: 'square' },
|
||||
],
|
||||
leave: [
|
||||
{ frequency: 523, offset: 0, duration: 0.11, type: 'triangle' },
|
||||
{ frequency: 392, offset: 0.09, duration: 0.18, type: 'triangle' },
|
||||
],
|
||||
share: [
|
||||
{ frequency: 523, offset: 0, duration: 0.08, type: 'square' },
|
||||
{ frequency: 659, offset: 0.07, duration: 0.08, type: 'square' },
|
||||
{ frequency: 784, offset: 0.14, duration: 0.08, type: 'square' },
|
||||
{ frequency: 1047, offset: 0.21, duration: 0.22, type: 'triangle' },
|
||||
],
|
||||
};
|
||||
const cue = cues[name];
|
||||
if (!cue) return;
|
||||
|
||||
const start = ctx.currentTime;
|
||||
for (const note of cue) {
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const noteStart = start + note.offset;
|
||||
oscillator.type = note.type;
|
||||
oscillator.frequency.setValueAtTime(note.frequency, noteStart);
|
||||
gain.gain.setValueAtTime(0.0001, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.055, noteStart + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, noteStart + note.duration);
|
||||
oscillator.connect(gain).connect(ctx.destination);
|
||||
oscillator.start(noteStart);
|
||||
oscillator.stop(noteStart + note.duration + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
const PEER_COLORS = ['#5865f2', '#23a55a', '#f0b232', '#eb459e', '#3ba55c', '#faa61a', '#e67e22', '#9b59b6'];
|
||||
function colorFor(id) {
|
||||
let h = 0;
|
||||
@@ -88,6 +129,60 @@ function effectiveIce() {
|
||||
return S.iceServers || FALLBACK_ICE;
|
||||
}
|
||||
|
||||
function microphoneConstraints(deviceId = localStorage.getItem(LS_AUDIO_INPUT)) {
|
||||
const audio = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
||||
if (deviceId) audio.deviceId = { exact: deviceId };
|
||||
return { audio };
|
||||
}
|
||||
|
||||
async function populateAudioInputs() {
|
||||
const select = $('#audio-input');
|
||||
if (!select || !navigator.mediaDevices?.enumerateDevices) return;
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const inputs = devices.filter((device) => device.kind === 'audioinput');
|
||||
const selected = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
||||
select.replaceChildren(new Option('System default', ''));
|
||||
inputs.forEach((device, index) => {
|
||||
select.add(new Option(device.label || `Microphone ${index + 1}`, device.deviceId));
|
||||
});
|
||||
select.value = inputs.some((device) => device.deviceId === selected) ? selected : '';
|
||||
if (selected && !select.value) localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
} catch (err) {
|
||||
console.warn('enumerating audio inputs failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function switchAudioInput(deviceId) {
|
||||
const previousDeviceId = localStorage.getItem(LS_AUDIO_INPUT) || '';
|
||||
if (deviceId) localStorage.setItem(LS_AUDIO_INPUT, deviceId);
|
||||
else localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
if (!S.room || !S.micStream) return;
|
||||
|
||||
let nextStream;
|
||||
try {
|
||||
nextStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints(deviceId));
|
||||
} catch (err) {
|
||||
if (previousDeviceId) localStorage.setItem(LS_AUDIO_INPUT, previousDeviceId);
|
||||
else localStorage.removeItem(LS_AUDIO_INPUT);
|
||||
$('#audio-input').value = previousDeviceId;
|
||||
toast(`Could not use that microphone: ${err.message || err}`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTrack = nextStream.getAudioTracks()[0];
|
||||
nextTrack.enabled = !S.muted;
|
||||
await Promise.all([...S.peers.values()].map(async (peer) => {
|
||||
if (!peer.pc) return;
|
||||
const sender = peer.pc.getSenders().find((item) => item.track && item.track.kind === 'audio');
|
||||
if (sender) await sender.replaceTrack(nextTrack);
|
||||
}));
|
||||
S.micStream.getTracks().forEach((track) => track.stop());
|
||||
S.micStream = nextStream;
|
||||
updateMicButton();
|
||||
toast('Microphone changed', 'ok');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// join / leave
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -113,9 +208,8 @@ async function joinVoice(room) {
|
||||
window.addEventListener('keydown', resumeCtx, { passive: true });
|
||||
|
||||
try {
|
||||
S.micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
S.micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints());
|
||||
populateAudioInputs();
|
||||
} catch {
|
||||
S.micStream = null;
|
||||
toast('No microphone available — joining in listen-only mode', 'warn');
|
||||
@@ -323,12 +417,14 @@ function handleSignal(msg) {
|
||||
}
|
||||
case 'joined':
|
||||
S.peers.set(msg.peer.id, makePeerRecord(msg.peer));
|
||||
playRetroCue('join');
|
||||
renderAll();
|
||||
toast(`${msg.peer.name} joined`, 'ok');
|
||||
break;
|
||||
case 'left': {
|
||||
const peer = S.peers.get(msg.id);
|
||||
if (peer) {
|
||||
playRetroCue('leave');
|
||||
destroyPeer(peer);
|
||||
S.peers.delete(msg.id);
|
||||
renderAll();
|
||||
@@ -530,11 +626,13 @@ function onRemoteTrack(peer, ev) {
|
||||
const stream = (ev.streams && ev.streams[0]) ? ev.streams[0] : new MediaStream([track]);
|
||||
|
||||
if (track.kind === 'video') {
|
||||
const isNewScreenShare = peer.screenStream !== stream;
|
||||
peer.screenStream = stream;
|
||||
track.addEventListener('ended', () => {
|
||||
if (peer.screenStream === stream) { peer.screenStream = null; renderScreens(); }
|
||||
});
|
||||
renderScreens();
|
||||
if (isNewScreenShare) playRetroCue('share');
|
||||
console.log('remote screen share from', peer.name);
|
||||
} else if (track.kind === 'audio') {
|
||||
peer.micStream = stream;
|
||||
@@ -648,6 +746,7 @@ async function startScreenShare() {
|
||||
}
|
||||
S.screenStream = stream;
|
||||
S.sharing = true;
|
||||
playRetroCue('share');
|
||||
send({ type: 'state', state: { muted: S.muted, deafened: S.deafened, sharing: true } });
|
||||
|
||||
for (const peer of S.peers.values()) addScreenToPeer(peer);
|
||||
@@ -1769,11 +1868,13 @@ window.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 's') (S.sharing ? stopScreenShare() : startScreenShare());
|
||||
});
|
||||
window.addEventListener('beforeunload', () => { try { cancelReconnect(); leaveVoice(); } catch {} });
|
||||
navigator.mediaDevices?.addEventListener('devicechange', populateAudioInputs);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// settings modal (custom STUN/TURN)
|
||||
// ---------------------------------------------------------------------------
|
||||
function openSettings() {
|
||||
populateAudioInputs();
|
||||
const override = localStorage.getItem(LS_ICE);
|
||||
$('#override-enabled').checked = !!override;
|
||||
const current = override ? JSON.parse(override) : effectiveIce();
|
||||
@@ -1784,6 +1885,7 @@ function openSettings() {
|
||||
);
|
||||
$('#modal-backdrop').classList.remove('hidden');
|
||||
}
|
||||
$('#audio-input').onchange = (ev) => switchAudioInput(ev.target.value);
|
||||
$('#ice-cancel').onclick = () => $('#modal-backdrop').classList.add('hidden');
|
||||
$('#modal-backdrop').onclick = (ev) => {
|
||||
if (ev.target === $('#modal-backdrop')) $('#modal-backdrop').classList.add('hidden');
|
||||
|
||||
@@ -131,6 +131,12 @@
|
||||
<div id="modal-backdrop" class="hidden">
|
||||
<div id="settings-modal" role="dialog" aria-modal="true">
|
||||
<h2>Voice & ICE settings</h2>
|
||||
<h3>Audio input</h3>
|
||||
<label class="field-label" for="audio-input">Microphone</label>
|
||||
<select id="audio-input" class="settings-select">
|
||||
<option value="">System default</option>
|
||||
</select>
|
||||
<p class="hint">Changes apply immediately when connected, or the next time you join.</p>
|
||||
<p class="hint">Overrides the server-provided ICE configuration (stored in <code>localStorage</code>, applied on next join).</p>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="override-enabled" />
|
||||
|
||||
@@ -372,6 +372,11 @@ body.sidebar-collapsed #btn-sidebar-expand { display: grid; }
|
||||
#settings-modal h2 { font-size: 18px; margin-bottom: 6px; }
|
||||
#settings-modal h3 { font-size: 13px; margin: 16px 0 4px; color: var(--text-1); }
|
||||
#settings-modal p { margin-bottom: 10px; }
|
||||
.settings-select {
|
||||
width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4);
|
||||
border-radius: 6px; padding: 9px 10px; margin-bottom: 4px; font: inherit;
|
||||
}
|
||||
.settings-select:focus { outline: none; border-color: var(--brand); }
|
||||
#ice-json {
|
||||
width: 100%; background: var(--bg-0); color: var(--text-0); border: 1px solid var(--bg-4);
|
||||
border-radius: 6px; padding: 10px; font: 12px/1.5 ui-monospace, "Cascadia Code", monospace;
|
||||
|
||||
Reference in new issue
Block a user