some tweaks
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 33s

This commit is contained in:
mats committed 2026-09-22 14:50:11 +02:00
1 parent d4332601b6
commit 75c8350573
3 files changed
+116 -3

No files matched your search

+105 -3
View File
@@ -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');
+6
View File
@@ -131,6 +131,12 @@
<div id="modal-backdrop" class="hidden">
<div id="settings-modal" role="dialog" aria-modal="true">
<h2>Voice &amp; 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" />
+5
View File
@@ -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;