fixed breakout room issue and added noise cancelling and car mode

This commit is contained in:
2026-07-23 05:51:29 +00:00
parent 0e369c9e27
commit e41f96efd9
13 changed files with 934 additions and 21 deletions
+28 -4
View File
@@ -39,6 +39,8 @@ DIAL_CODES = {
'*420#': 'trippy_toggle', # toggles UI trippy mode globally for everyone
'*1337#': 'rainbow_nick_toggle', # toggles the dialer's own rainbow nick
'*101#': 'knock', # plays a knock sound for everyone in the room
'*300#': 'laugh', # plays a laugh sound for everyone in the room
'*88#': 'voice_changer', # opens the dialer's voice changer popup (local FX)
'*666#': 'schizo_toggle', # toggles schizo mode (subtle UI shake/wiggle/morph)
'*9059#': 'pong_toggle', # toggles pong mode (webcam tiles bounce around)
'*401#': 'ghost_toggle', # hides the dialer's nick from everyone's user list
@@ -54,6 +56,8 @@ DIAL_CODE_DESCRIPTIONS = [
('*420#', 'Toggle trippy color mode (everyone)'),
('*1337#', 'Toggle rainbow nickname (just you)'),
('*101#', 'Play a knock sound (everyone)'),
('*300#', 'Play a laugh sound (everyone)'),
('*88#', 'Open the voice changer (just you)'),
('*666#', 'Toggle schizo mode (everyone)'),
('*9059#', 'Toggle pong mode (everyone)'),
('*401#', 'Toggle ghost mode (hide your nick)'),
@@ -239,7 +243,7 @@ async def websocket_handler(request: web.Request) -> web.WebSocketResponse:
await ws.prepare(request)
client_id = str(uuid.uuid4())[:8]
clients[client_id] = {'ws': ws, 'username': None, 'cam_on': False, 'mic_on': True, 'screen_on': False, 'rainbow_nick': False, 'ghost': False, 'fed': False, 'breakout': False}
clients[client_id] = {'ws': ws, 'username': None, 'cam_on': False, 'mic_on': True, 'screen_on': False, 'rainbow_nick': False, 'ghost': False, 'fed': False, 'breakout': False, 'audio_only': False}
logging.info(f'[{client_id}] Connected')
@@ -302,7 +306,7 @@ async def handle_message(client_id: str, data: dict):
reconnect_tokens[reconnect_token] = {'username': username, 'expires': time.time() + 3600}
users = [
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False)}
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False), 'audio_only': c.get('audio_only', False)}
for cid, c in clients.items()
if c['username'] and cid != client_id
]
@@ -368,7 +372,7 @@ async def handle_message(client_id: str, data: dict):
reconnect_tokens[new_token] = {'username': username, 'expires': time.time() + 3600}
users = [
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False)}
{'id': cid, 'username': c['username'], 'cam_on': c.get('cam_on', False), 'mic_on': c.get('mic_on', True), 'screen_on': c.get('screen_on', False), 'rainbow_nick': c.get('rainbow_nick', False), 'ghost': c.get('ghost', False), 'fed': c.get('fed', False), 'breakout': c.get('breakout', False), 'audio_only': c.get('audio_only', False)}
for cid, c in clients.items()
if c['username'] and cid != client_id
]
@@ -448,6 +452,19 @@ async def handle_message(client_id: str, data: dict):
'enabled' : enabled
})
elif msg_type == 'car_mode':
# Car mode = audio-only. We track the flag and fan it out so every other client
# pauses the video/screen streams they send to this user (client-side, via
# RTCRtpSender encodings.active). Server just relays state.
enabled = bool(data.get('enabled', False))
clients[client_id]['audio_only'] = enabled
logging.info(f'[{client_id}] Car mode -> {enabled}')
await broadcast_all({
'type' : 'car_mode_status',
'id' : client_id,
'audio_only' : enabled
})
elif msg_type == 'leave':
# Explicit leave message for immediate cleanup (triggered on tab close)
await cleanup(client_id)
@@ -493,6 +510,14 @@ async def handle_message(client_id: str, data: dict):
elif action == 'knock':
logging.info(f'[{client_id}] Knock')
await broadcast_all({'type': 'play_sound', 'sound': 'knock'})
elif action == 'laugh':
logging.info(f'[{client_id}] Laugh')
await broadcast_all({'type': 'play_sound', 'sound': 'laugh'})
elif action == 'voice_changer':
# Private trigger - only the dialer's UI opens the voice changer popup. The FX
# are applied client-side to the dialer's own outgoing audio.
logging.info(f'[{client_id}] Voice changer open')
await clients[client_id]['ws'].send_json({'type': 'voice_changer_open'})
elif action == 'schizo_toggle':
schizo_mode = not schizo_mode
logging.info(f'[{client_id}] Schizo mode -> {schizo_mode}')
@@ -510,7 +535,6 @@ async def handle_message(client_id: str, data: dict):
for c in clients.values():
c['rainbow_nick'] = False
c['ghost'] = False
c['breakout'] = False
logging.info(f'[{client_id}] Reset all modes')
await broadcast_all({'type': 'reset_all'})
elif action == 'breakout_toggle':
+108
View File
@@ -0,0 +1,108 @@
<!-- hardchats - Developed by acidvegas in HTML (https://github.com/acidvegas) -->
<!-- static/audiofx-test.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HardChats Voice FX - Isolated Test</title>
<style>
body { font-family: system-ui, sans-serif; background: #0a0a0a; color: #eee; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
h1 { color: #a3e635; }
.warn { background: #3a1a1a; border: 1px solid #a33; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.9rem; }
button { background: #1a1a1a; color: #eee; border: 1px solid #444; border-radius: 6px; padding: 0.5rem 0.9rem; cursor: pointer; font-size: 0.9rem; }
button.on { border-color: #a3e635; color: #a3e635; }
.presets button { margin: 0.2rem; }
.row { display: flex; align-items: center; gap: 0.75rem; margin: 0.6rem 0; }
.row label { width: 110px; font-size: 0.9rem; }
.row input[type=range] { flex: 1; accent-color: #a3e635; }
.val { width: 48px; text-align: right; font-family: monospace; color: #a3e635; }
code { background: #1a1a1a; padding: 0.1rem 0.35rem; border-radius: 4px; }
section { border: 1px solid #333; border-radius: 8px; padding: 1rem; margin: 1rem 0; }
</style>
</head>
<body>
<h1>Voice FX &mdash; Isolated Test</h1>
<p class="warn"><b>Use headphones.</b> This loops your mic straight back to your speakers to let you hear the effect &mdash; without headphones you'll get feedback/echo. This page is not part of the app; it just loads the same <code>audiofx.js</code> DSP so you can verify it (especially <b>Pitch</b>) before it's trusted in the live room.</p>
<section>
<button id="start">Start mic loopback</button>
<span id="status" style="margin-left:0.75rem; color:#888;">idle</span>
</section>
<section id="controls" style="display:none;">
<div class="presets">
<b style="display:block;margin-bottom:0.4rem;">Presets:</b>
<button data-preset="none">Normal</button>
<button data-preset="chipmunk">Chipmunk</button>
<button data-preset="demon">Demon</button>
<button data-preset="robot">Robot</button>
<button data-preset="telephone">Telephone</button>
<button data-preset="alien">Alien</button>
</div>
<div class="row"><label>Pitch (semitones)</label><input type="range" id="pitch" min="-12" max="12" step="1" value="0"><span class="val" id="pitch-v">0</span></div>
<div class="row"><label>Reverb</label><input type="range" id="reverb" min="0" max="100" value="0"><span class="val" id="reverb-v">0%</span></div>
<div class="row"><label>Distortion</label><input type="range" id="distortion" min="0" max="100" value="0"><span class="val" id="distortion-v">0%</span></div>
<div class="row"><label>Robot</label><input type="range" id="robot" min="0" max="100" value="0"><span class="val" id="robot-v">0%</span></div>
<div class="row"><label>Telephone</label><button id="telephone">off</button></div>
</section>
<script src="/static/js/audiofx.js"></script>
<script>
const PRESETS = {
none: { pitch: 0, reverb: 0, distortion: 0, robot: 0, telephone: false },
chipmunk: { pitch: 7, reverb: 0, distortion: 0, robot: 0, telephone: false },
demon: { pitch: -7, reverb: 0.25, distortion: 0.15, robot: 0, telephone: false },
robot: { pitch: 0, reverb: 0, distortion: 0, robot: 0.9, telephone: false },
telephone: { pitch: 0, reverb: 0, distortion: 0.1, robot: 0, telephone: true },
alien: { pitch: 4, reverb: 0.5, distortion: 0, robot: 0.4, telephone: false }
};
let ctx, graph, params = { pitch: 0, reverb: 0, distortion: 0, robot: 0, telephone: false };
const $ = (id) => document.getElementById(id);
function apply() {
graph.setPitch(params.pitch);
graph.setReverb(params.reverb);
graph.setDistortion(params.distortion);
graph.setRobot(params.robot);
graph.setTelephone(params.telephone);
}
function sync() {
$('pitch').value = params.pitch; $('pitch-v').textContent = (params.pitch > 0 ? '+' : '') + params.pitch;
$('reverb').value = Math.round(params.reverb * 100); $('reverb-v').textContent = Math.round(params.reverb * 100) + '%';
$('distortion').value = Math.round(params.distortion * 100); $('distortion-v').textContent = Math.round(params.distortion * 100) + '%';
$('robot').value = Math.round(params.robot * 100); $('robot-v').textContent = Math.round(params.robot * 100) + '%';
const t = $('telephone'); t.textContent = params.telephone ? 'on' : 'off'; t.classList.toggle('on', params.telephone);
}
$('start').addEventListener('click', async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
ctx = new (window.AudioContext || window.webkitAudioContext)();
await ctx.resume();
graph = window.HardChatsAudioFX.buildVoiceGraph(ctx);
const src = ctx.createMediaStreamSource(stream);
src.connect(graph.input);
graph.output.connect(ctx.destination); // loopback so you can hear it
apply(); sync();
$('status').textContent = 'running - speak (with headphones!)';
$('controls').style.display = 'block';
$('start').disabled = true;
} catch (e) {
$('status').textContent = 'mic error: ' + (e && e.message);
}
});
document.querySelectorAll('.presets button').forEach(b => b.addEventListener('click', () => {
Object.assign(params, PRESETS[b.dataset.preset]); apply(); sync();
}));
$('pitch').addEventListener('input', e => { params.pitch = Number(e.target.value); apply(); sync(); });
$('reverb').addEventListener('input', e => { params.reverb = Number(e.target.value) / 100; apply(); sync(); });
$('distortion').addEventListener('input', e => { params.distortion = Number(e.target.value) / 100; apply(); sync(); });
$('robot').addEventListener('input', e => { params.robot = Number(e.target.value) / 100; apply(); sync(); });
$('telephone').addEventListener('click', () => { params.telephone = !params.telephone; apply(); sync(); });
</script>
</body>
</html>
+80
View File
@@ -265,6 +265,17 @@
<span class="toggle-slider"></span>
</button>
</div>
<div class="settings-toggle">
<label>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/>
</svg>
Noise Suppression <span class="settings-hint">(mic)</span>
</label>
<button id="toggle-noisesuppression" class="toggle-btn" data-enabled="true">
<span class="toggle-slider"></span>
</button>
</div>
<div class="settings-toggle">
<label>
<svg viewBox="0 0 24 24" fill="currentColor">
@@ -276,6 +287,17 @@
<span class="toggle-slider"></span>
</button>
</div>
<div class="settings-toggle">
<label>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/>
</svg>
Car Mode <span class="settings-hint">(audio only)</span>
</label>
<button id="toggle-carmode" class="toggle-btn" data-enabled="false">
<span class="toggle-slider"></span>
</button>
</div>
<div class="settings-toggle mobile-only-setting">
<label>
<svg viewBox="0 0 24 24" fill="currentColor">
@@ -385,6 +407,63 @@
<span class="fed-fake-time" id="fed-fake-time">00:00</span>
</div>
<!-- Voice changer popup (opened via *88#). FX are applied to the dialer's own
outgoing audio; everyone in the room hears the altered voice. -->
<div id="voice-changer-modal" class="modal hidden">
<div class="modal-content vc-modal">
<div class="modal-header">
<h3>Voice Changer</h3>
<button id="voice-changer-close" class="modal-close-btn" title="Close">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
</svg>
</button>
</div>
<div class="vc-body">
<div class="vc-row vc-row-toggles">
<div class="vc-toggle-item">
<span>Enabled</span>
<button id="vc-enable" class="toggle-btn" data-enabled="false"><span class="toggle-slider"></span></button>
</div>
<div class="vc-toggle-item">
<span>Hear myself</span>
<button id="vc-monitor" class="toggle-btn" data-enabled="false"><span class="toggle-slider"></span></button>
</div>
</div>
<div class="vc-presets">
<button class="vc-preset btn-secondary" data-preset="none">Normal</button>
<button class="vc-preset btn-secondary" data-preset="chipmunk">Chipmunk</button>
<button class="vc-preset btn-secondary" data-preset="demon">Demon</button>
<button class="vc-preset btn-secondary" data-preset="robot">Robot</button>
<button class="vc-preset btn-secondary" data-preset="telephone">Telephone</button>
<button class="vc-preset btn-secondary" data-preset="alien">Alien</button>
</div>
<label class="vc-slider">
<span class="vc-slider-label">Pitch <em id="vc-pitch-val">0</em></span>
<input type="range" id="vc-pitch" min="-12" max="12" step="1" value="0">
</label>
<label class="vc-slider">
<span class="vc-slider-label">Reverb <em id="vc-reverb-val">0%</em></span>
<input type="range" id="vc-reverb" min="0" max="100" step="1" value="0">
</label>
<label class="vc-slider">
<span class="vc-slider-label">Distortion <em id="vc-distortion-val">0%</em></span>
<input type="range" id="vc-distortion" min="0" max="100" step="1" value="0">
</label>
<label class="vc-slider">
<span class="vc-slider-label">Robot <em id="vc-robot-val">0%</em></span>
<input type="range" id="vc-robot" min="0" max="100" step="1" value="0">
</label>
<div class="vc-toggle-item vc-telephone-row">
<span>Telephone filter</span>
<button id="vc-telephone" class="toggle-btn" data-enabled="false"><span class="toggle-slider"></span></button>
</div>
</div>
</div>
</div>
<!-- Dial codes list modal (shown only to the dialer who hits *#06#) -->
<div id="dial-codes-modal" class="modal hidden">
<div class="modal-content">
@@ -427,6 +506,7 @@
<script src="/static/js/irc.js"></script>
<script src="/static/js/dial.js"></script>
<script src="/static/js/recording.js"></script>
<script src="/static/js/audiofx.js"></script>
<script src="/static/js/client.js"></script>
</body>
</html>
+442
View File
@@ -0,0 +1,442 @@
// HardChats - Outgoing audio FX (voice changer)
// Requires: state, $ from state.js; getAudioCtx from webrtc.js; applyBreakoutGatingAll from client.js
//
// The FX graph sits between the raw mic and every peer's audio sender:
// raw mic track -> MediaStreamAudioSourceNode -> [fx chain] -> MediaStreamAudioDestination
// -> processedTrack (what every sender transmits while the changer is ON)
//
// getOutgoingAudioTrack() is the single source of truth every sender assignment uses
// (breakout gating calls it). When the changer is OFF it returns the raw mic track, so
// nothing about normal operation changes for users who never open the popup.
//
// NOTE: the pitch shifter (createPitchShifter) uses the well-known "Jungle" delay-line
// technique. It must be ear-verified in a real browser via /static/audiofx-test.html
// (there is no headless WebAudio in the build environment).
// ---------- Pure DSP building blocks (no app state; reused by the test harness) ----------
// Waveshaper distortion curve. amount 0 -> caller should bypass (curve=null).
function makeDistortionCurve(amount) {
const k = amount * 100;
const n = 8192;
const curve = new Float32Array(n);
for (let i = 0; i < n; i++) {
const x = (i * 2) / n - 1;
curve[i] = ((3 + k) * x * 20 * (Math.PI / 180)) / (Math.PI + k * Math.abs(x));
}
return curve;
}
// Synthetic reverb impulse response (decaying white noise). No asset file needed.
function makeImpulseResponse(ctx, seconds, decay) {
const rate = ctx.sampleRate;
const len = Math.max(1, Math.floor(rate * seconds));
const impulse = ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const data = impulse.getChannelData(ch);
for (let i = 0; i < len; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
return impulse;
}
// Ring modulator (robot voice). depth 0 = clean passthrough, 1 = full ring mod.
function createRingMod(ctx) {
const input = ctx.createGain();
const output = ctx.createGain();
const dry = ctx.createGain();
const ring = ctx.createGain();
const osc = ctx.createOscillator();
const depthGain = ctx.createGain();
dry.gain.value = 1; // 1 - depth
ring.gain.value = 0; // base 0; carrier drives it
depthGain.gain.value = 0;
osc.frequency.value = 50;
osc.connect(depthGain);
depthGain.connect(ring.gain);
input.connect(dry); dry.connect(output);
input.connect(ring); ring.connect(output);
osc.start();
return {
input, output, osc,
setDepth(d) { dry.gain.value = 1 - d; depthGain.gain.value = d; },
setFreq(f) { osc.frequency.value = f; }
};
}
// Jungle pitch shifter (Chris Wilson, WebAudio). Real-time granular pitch shift via two
// crossfaded, ramp-modulated delay lines. setPitchOffset(mult): >0 up, <0 down, 0 flat.
function createPitchShifter(ctx) {
const delayTime = 0.100;
const fadeTime = 0.050;
const bufferTime = 0.100;
function createFadeBuffer(activeTime, fadeT) {
const length1 = activeTime * ctx.sampleRate;
const length2 = (activeTime - 2 * fadeT) * ctx.sampleRate;
const length = length1 + length2;
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
const p = buffer.getChannelData(0);
const fadeLength = fadeT * ctx.sampleRate;
const fadeIndex1 = fadeLength;
const fadeIndex2 = length1 - fadeLength;
let i;
for (i = 0; i < length1; ++i) {
let value;
if (i < fadeIndex1) value = Math.sqrt(i / fadeLength);
else if (i >= fadeIndex2) value = Math.sqrt(1 - (i - fadeIndex2) / fadeLength);
else value = 1;
p[i] = value;
}
for (; i < length; ++i) p[i] = 0;
return buffer;
}
function createDelayTimeBuffer(activeTime, fadeT, shiftUp) {
const length1 = activeTime * ctx.sampleRate;
const length2 = (activeTime - 2 * fadeT) * ctx.sampleRate;
const length = length1 + length2;
const buffer = ctx.createBuffer(1, length, ctx.sampleRate);
const p = buffer.getChannelData(0);
let i;
for (i = 0; i < length1; ++i) {
if (shiftUp) p[i] = (length1 - i) / length;
else p[i] = i / length1;
}
for (; i < length; ++i) p[i] = 0;
return buffer;
}
const input = ctx.createGain();
const output = ctx.createGain();
const mod1 = ctx.createBufferSource();
const mod2 = ctx.createBufferSource();
const mod3 = ctx.createBufferSource();
const mod4 = ctx.createBufferSource();
const shiftDownBuffer = createDelayTimeBuffer(bufferTime, fadeTime, false);
const shiftUpBuffer = createDelayTimeBuffer(bufferTime, fadeTime, true);
mod1.buffer = shiftDownBuffer;
mod2.buffer = shiftDownBuffer;
mod3.buffer = shiftUpBuffer;
mod4.buffer = shiftUpBuffer;
mod1.loop = mod2.loop = mod3.loop = mod4.loop = true;
const mod1Gain = ctx.createGain();
const mod2Gain = ctx.createGain();
const mod3Gain = ctx.createGain();
const mod4Gain = ctx.createGain();
mod3Gain.gain.value = 0;
mod4Gain.gain.value = 0;
const modGain1 = ctx.createGain();
const modGain2 = ctx.createGain();
const delay1 = ctx.createDelay();
const delay2 = ctx.createDelay();
mod1.connect(mod1Gain);
mod2.connect(mod2Gain);
mod3.connect(mod3Gain);
mod4.connect(mod4Gain);
mod1Gain.connect(modGain1);
mod2Gain.connect(modGain2);
mod3Gain.connect(modGain1);
mod4Gain.connect(modGain2);
modGain1.connect(delay1.delayTime);
modGain2.connect(delay2.delayTime);
const fade1 = ctx.createBufferSource();
const fade2 = ctx.createBufferSource();
const fadeBuffer = createFadeBuffer(bufferTime, fadeTime);
fade1.buffer = fadeBuffer;
fade2.buffer = fadeBuffer;
fade1.loop = fade2.loop = true;
const mix1 = ctx.createGain();
const mix2 = ctx.createGain();
mix1.gain.value = 0;
mix2.gain.value = 0;
fade1.connect(mix1.gain);
fade2.connect(mix2.gain);
input.connect(delay1);
input.connect(delay2);
delay1.connect(mix1);
delay2.connect(mix2);
mix1.connect(output);
mix2.connect(output);
const t = ctx.currentTime + 0.050;
const interval = bufferTime - fadeTime;
mod1.start(t);
mod2.start(t + interval);
fade1.start(t);
fade2.start(t + interval);
mod3.start(t);
mod4.start(t + interval);
function setDelay(d) {
modGain1.gain.setTargetAtTime(0.5 * d, ctx.currentTime, 0.010);
modGain2.gain.setTargetAtTime(0.5 * d, ctx.currentTime, 0.010);
}
// mult is in octaves-ish: +1 ~ up an octave, -1 ~ down an octave, 0 = flat.
function setPitchOffset(mult) {
if (mult > 0) {
mod1Gain.gain.value = 0; mod2Gain.gain.value = 0;
mod3Gain.gain.value = 1; mod4Gain.gain.value = 1;
} else {
mod1Gain.gain.value = 1; mod2Gain.gain.value = 1;
mod3Gain.gain.value = 0; mod4Gain.gain.value = 0;
}
setDelay(delayTime * Math.abs(mult));
}
setPitchOffset(0);
return { input, output, setPitchOffset };
}
// Build the full voice FX chain on ctx. Returns { input, output, set* } with all effects
// live-adjustable (no rebuild needed for parameter changes).
function buildVoiceGraph(ctx) {
const input = ctx.createGain();
const output = ctx.createGain();
const pitch = createPitchShifter(ctx);
const ringMod = createRingMod(ctx);
const shaper = ctx.createWaveShaper();
const filter = ctx.createBiquadFilter();
filter.type = 'allpass'; // flat until telephone enabled
const convolver = ctx.createConvolver();
convolver.buffer = makeImpulseResponse(ctx, 2.0, 3.0);
const wetGain = ctx.createGain();
const dryGain = ctx.createGain();
wetGain.gain.value = 0;
dryGain.gain.value = 1;
// input -> pitch -> ringMod -> shaper -> filter -> [dry + reverb] -> output
input.connect(pitch.input);
pitch.output.connect(ringMod.input);
ringMod.output.connect(shaper);
shaper.connect(filter);
filter.connect(dryGain);
dryGain.connect(output);
filter.connect(convolver);
convolver.connect(wetGain);
wetGain.connect(output);
return {
input,
output,
// pitch in semitones (-12..+12). Convert to Jungle octave multiplier.
setPitch(semitones) { pitch.setPitchOffset((semitones || 0) / 12); },
setRobot(depth) { ringMod.setDepth(Math.max(0, Math.min(1, depth || 0))); },
setRobotFreq(f) { ringMod.setFreq(f); },
setDistortion(amount) { shaper.curve = amount > 0 ? makeDistortionCurve(amount) : null; },
setTelephone(on) {
if (on) { filter.type = 'bandpass'; filter.frequency.value = 1700; filter.Q.value = 0.7; }
else { filter.type = 'allpass'; }
},
setReverb(amount) {
const a = Math.max(0, Math.min(1, amount || 0));
wetGain.gain.value = a;
dryGain.gain.value = 1 - 0.5 * a; // keep some dry so voice stays intelligible
}
};
}
// Expose the pure DSP for the standalone test harness.
window.HardChatsAudioFX = { buildVoiceGraph, createPitchShifter, createRingMod, makeDistortionCurve, makeImpulseResponse };
// ---------- App integration (voice changer state + wiring) ----------
let fxActive = false; // voice changer engaged
let fxGraph = null; // buildVoiceGraph() result
let fxSource = null; // MediaStreamAudioSourceNode reading the raw mic
let fxDest = null; // MediaStreamAudioDestinationNode -> processed track
let fxMonitorEl = null; // optional self-monitor <audio>
// Default params. Presets and sliders mutate this; applyFxParams pushes to the graph.
const fxParams = {
pitch: 0, // semitones
reverb: 0, // 0..1
distortion: 0, // 0..1
robot: 0, // 0..1
telephone: false
};
// The one track every audio sender should carry. Falls back to the raw mic when the
// changer is off. Breakout gating calls this exclusively.
function getOutgoingAudioTrack() {
if (fxActive && fxDest) {
const t = fxDest.stream.getAudioTracks()[0];
if (t) return t;
}
return state.localStream?.getAudioTracks()[0] || null;
}
function ensureFxGraph() {
const ctx = getAudioCtx();
if (!ctx) return false;
if (!fxGraph) fxGraph = buildVoiceGraph(ctx);
if (!fxDest) {
fxDest = ctx.createMediaStreamDestination();
fxGraph.output.connect(fxDest);
}
buildFxSource(ctx);
applyFxParams();
return true;
}
// (Re)create the source node from the current raw mic and connect it to the graph head.
function buildFxSource(ctx) {
if (!ctx || !state.localStream) return;
if (fxSource) { try { fxSource.disconnect(); } catch (e) {} fxSource = null; }
try {
fxSource = ctx.createMediaStreamSource(state.localStream);
fxSource.connect(fxGraph.input);
} catch (e) {
console.warn('[FX] buildFxSource failed:', e?.message || e);
}
}
// Called on mic-device switch (from settings.js) to re-point the graph at the new mic.
function rebuildVoiceFxSource() {
if (!fxActive || !fxGraph) return;
const ctx = getAudioCtx();
buildFxSource(ctx);
}
function applyFxParams() {
if (!fxGraph) return;
fxGraph.setPitch(fxParams.pitch);
fxGraph.setReverb(fxParams.reverb);
fxGraph.setDistortion(fxParams.distortion);
fxGraph.setRobot(fxParams.robot);
fxGraph.setTelephone(fxParams.telephone);
}
// Turn the changer on/off. Pushing the correct outgoing track to every peer is done via
// the existing breakout-gating path (which reads getOutgoingAudioTrack()).
function setVoiceChangerActive(on) {
if (on) {
if (!ensureFxGraph()) return;
fxActive = true;
} else {
fxActive = false;
}
if (typeof applyBreakoutGatingAll === 'function') applyBreakoutGatingAll();
updateMonitor();
}
// Optional self-monitor: hear your own processed voice locally. Off by default (speaker
// output risks echo). Routed through a dedicated element, muted unless enabled.
function updateMonitor() {
const wantMonitor = fxActive && $('vc-monitor')?.dataset.enabled === 'true';
if (wantMonitor) {
if (!fxMonitorEl) {
fxMonitorEl = document.createElement('audio');
fxMonitorEl.autoplay = true;
fxMonitorEl.id = 'vc-monitor-audio';
(document.getElementById('peer-audio-container') || document.body).appendChild(fxMonitorEl);
}
if (fxDest && fxMonitorEl.srcObject !== fxDest.stream) fxMonitorEl.srcObject = fxDest.stream;
fxMonitorEl.muted = false;
fxMonitorEl.play?.().catch(() => {});
} else if (fxMonitorEl) {
fxMonitorEl.muted = true;
}
}
// ---------- Popup UI ----------
const VC_PRESETS = {
none: { pitch: 0, reverb: 0, distortion: 0, robot: 0, telephone: false },
chipmunk: { pitch: 7, reverb: 0, distortion: 0, robot: 0, telephone: false },
demon: { pitch: -7, reverb: 0.25, distortion: 0.15, robot: 0, telephone: false },
robot: { pitch: 0, reverb: 0, distortion: 0, robot: 0.9, telephone: false },
telephone: { pitch: 0, reverb: 0, distortion: 0.1, robot: 0, telephone: true },
alien: { pitch: 4, reverb: 0.5, distortion: 0, robot: 0.4, telephone: false }
};
function openVoiceChanger() {
$('voice-changer-modal')?.classList.remove('hidden');
syncVcControls();
}
function closeVoiceChanger() {
$('voice-changer-modal')?.classList.add('hidden');
}
// Reflect fxParams into the sliders/labels.
function syncVcControls() {
const p = $('vc-pitch'); if (p) p.value = fxParams.pitch;
const r = $('vc-reverb'); if (r) r.value = Math.round(fxParams.reverb * 100);
const d = $('vc-distortion'); if (d) d.value = Math.round(fxParams.distortion * 100);
const rb = $('vc-robot'); if (rb) rb.value = Math.round(fxParams.robot * 100);
const tel = $('vc-telephone'); if (tel) tel.dataset.enabled = String(fxParams.telephone);
const en = $('vc-enable'); if (en) en.dataset.enabled = String(fxActive);
updateVcLabels();
}
function updateVcLabels() {
const set = (id, v) => { const el = $(id); if (el) el.textContent = v; };
set('vc-pitch-val', (fxParams.pitch > 0 ? '+' : '') + fxParams.pitch);
set('vc-reverb-val', Math.round(fxParams.reverb * 100) + '%');
set('vc-distortion-val', Math.round(fxParams.distortion * 100) + '%');
set('vc-robot-val', Math.round(fxParams.robot * 100) + '%');
}
function applyPreset(name) {
const preset = VC_PRESETS[name];
if (!preset) return;
Object.assign(fxParams, preset);
applyFxParams();
syncVcControls();
if (!fxActive) { setVoiceChangerActive(true); const en = $('vc-enable'); if (en) en.dataset.enabled = 'true'; }
}
function initVoiceChangerListeners() {
$('voice-changer-close')?.addEventListener('click', closeVoiceChanger);
$('voice-changer-modal')?.addEventListener('click', (e) => {
if (e.target.id === 'voice-changer-modal') closeVoiceChanger();
});
$('vc-enable')?.addEventListener('click', () => {
const on = $('vc-enable').dataset.enabled !== 'true';
$('vc-enable').dataset.enabled = String(on);
setVoiceChangerActive(on);
});
$('vc-monitor')?.addEventListener('click', () => {
const on = $('vc-monitor').dataset.enabled !== 'true';
$('vc-monitor').dataset.enabled = String(on);
updateMonitor();
});
$('vc-telephone')?.addEventListener('click', () => {
fxParams.telephone = $('vc-telephone').dataset.enabled !== 'true';
$('vc-telephone').dataset.enabled = String(fxParams.telephone);
applyFxParams();
});
const bindSlider = (id, key, scale) => {
$(id)?.addEventListener('input', (e) => {
fxParams[key] = scale ? Number(e.target.value) / 100 : Number(e.target.value);
applyFxParams();
updateVcLabels();
});
};
bindSlider('vc-pitch', 'pitch', false);
bindSlider('vc-reverb', 'reverb', true);
bindSlider('vc-distortion', 'distortion', true);
bindSlider('vc-robot', 'robot', true);
document.querySelectorAll('.vc-preset').forEach(btn => {
btn.addEventListener('click', () => applyPreset(btn.dataset.preset));
});
}
+80 -6
View File
@@ -23,6 +23,7 @@ document.addEventListener('DOMContentLoaded', async () => {
$('username').addEventListener('keypress', (e) => e.key === 'Enter' && $('captcha-answer').focus());
$('captcha-answer').addEventListener('keypress', (e) => e.key === 'Enter' && connect());
$('refresh-captcha').addEventListener('click', loadCaptcha);
if (typeof initVoiceChangerListeners === 'function') initVoiceChangerListeners();
$('mic-btn').addEventListener('click', toggleMic);
$('cam-btn').addEventListener('click', toggleCam);
$('screen-btn').addEventListener('click', toggleScreen);
@@ -223,9 +224,7 @@ async function connect() {
try {
// Check for saved device preferences
const savedDevices = loadSavedDevices();
const audioConstraints = savedDevices?.micId
? { deviceId: { ideal: savedDevices.micId } }
: true;
const audioConstraints = getAudioConstraints(savedDevices?.micId, 'ideal');
state.localStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints, video: false });
@@ -375,12 +374,17 @@ function handleSignal(data) {
ghost: !!user.ghost,
fed: !!user.fed,
breakout: !!user.breakout,
audioOnly: !!user.audio_only,
speaking: false
};
createPeerConnection(user.id, user.username, true);
});
updateUI();
// If we joined with car mode on (persisted setting), tell peers to stop
// sending us video and apply our local audio-only shaping.
if (state.settings.carMode && typeof applyCarMode === 'function') applyCarMode();
if (!state.timerStarted) {
startTimer();
state.timerStarted = true;
@@ -522,6 +526,8 @@ function handleSignal(data) {
break;
case 'play_sound':
// Car mode silences dial-code sound effects (knock/laugh).
if (state.settings.carMode) break;
playSound(data.sound);
break;
@@ -533,9 +539,7 @@ function handleSignal(data) {
if (!u) return;
u.rainbowNick = false;
u.ghost = false;
u.breakout = false;
});
applyBreakoutGatingAll();
updateUsersList();
break;
@@ -547,14 +551,26 @@ function handleSignal(data) {
openRecordModal();
break;
case 'voice_changer_open':
openVoiceChanger();
break;
case 'request_broadcast_recording':
uploadRecording();
break;
case 'play_recording':
// Car mode silences broadcast recordings too.
if (state.settings.carMode) break;
playBroadcastRecording(data.audio, data.mime);
break;
case 'car_mode_status':
if (state.users[data.id]) state.users[data.id].audioOnly = !!data.audio_only;
// Stop / resume the video+screen we send to this peer.
applyAudioOnlyGatingForPeer(data.id);
break;
case 'fed_status':
// We never receive this for our own id (server filters).
if (state.users[data.id]) state.users[data.id].fed = !!data.fed;
@@ -633,7 +649,11 @@ function applyBreakoutGatingForPeer(peerId) {
// sender's track for null so they receive silence. track.enabled would mute the
// mic for every peer because the underlying MediaStreamTrack is shared. This
// way the regular mic on/off (toggleMic) still works for matching peers.
const localAudioTrack = state.localStream?.getAudioTracks()[0] || null;
// getOutgoingAudioTrack() (audiofx.js) returns the voice-changer's processed track
// when active, else the raw mic - so it's the single source of truth for what we send.
const localAudioTrack = (typeof getOutgoingAudioTrack === 'function')
? getOutgoingAudioTrack()
: (state.localStream?.getAudioTracks()[0] || null);
const targetTrack = canHear ? localAudioTrack : null;
if (peer.audioSender) {
// replaceTrack is transparent (no renegotiation needed). Avoid redundant calls.
@@ -652,6 +672,60 @@ function applyBreakoutGatingForPeer(peerId) {
peer.breakoutMuted = !canHear;
}
// ========== CAR MODE (audio-only) ==========
//
// Applied from the settings toggle (and re-applied on join if persisted on). Three parts:
// 1. We broadcast car_mode so every OTHER client pauses the video/screen they send us.
// 2. Locally we hide all video (updateVideoGrid short-circuits on state.settings.carMode)
// and suppress visual dial effects + their sounds.
// 3. We cap our own outgoing audio bitrate low.
// Pausing peers' outbound video to us is done via RTCRtpSender encodings.active=false
// (setParameters) - transparent, no renegotiation, and it never touches the track so it
// can't collide with the camera on/off pipeline.
const CARMODE_AUDIO_BITRATE = 24000; // ~24 kbps opus - plenty for intelligible speech
function applyCarMode() {
const on = !!state.settings.carMode;
send({ type: 'car_mode', enabled: on });
// Re-render dial effects against the new car-mode value (setters gate on it).
if (typeof setTrippyMode === 'function') setTrippyMode(state.trippyMode);
if (typeof setSchizoMode === 'function') setSchizoMode(state.schizoMode);
if (typeof setPongMode === 'function') setPongMode(state.pongMode);
applyCarModeAudioBitrate();
updateUI();
}
// Cap (or uncap) our outgoing audio bitrate on every peer when we're in car mode.
function applyCarModeAudioBitrate() {
const cap = state.settings.carMode ? CARMODE_AUDIO_BITRATE : undefined;
Object.values(state.peers).forEach(peer => {
if (!peer.audioSender) return;
const params = peer.audioSender.getParameters();
if (!params.encodings || !params.encodings.length) params.encodings = [{}];
params.encodings[0].maxBitrate = cap;
peer.audioSender.setParameters(params).catch(e => console.warn('[CarMode] audio setParameters failed:', e?.message || e));
});
}
// Pause/resume the video+screen RTP we send to a peer based on THEIR car-mode flag.
function applyAudioOnlyGatingForPeer(peerId) {
const peer = state.peers[peerId];
if (!peer || !peer.pc) return;
const peerAudioOnly = !!state.users[peerId]?.audioOnly;
peer.pc.getSenders().forEach(sender => {
if (!sender.track || sender.track.kind !== 'video') return;
const params = sender.getParameters();
if (!params.encodings || !params.encodings.length) params.encodings = [{}];
params.encodings[0].active = !peerAudioOnly;
sender.setParameters(params).catch(e => console.warn('[CarMode] video gate failed:', e?.message || e));
});
}
function applyAudioOnlyGatingAll() {
Object.keys(state.peers).forEach(applyAudioOnlyGatingForPeer);
}
// ========== TIMER ==========
function startTimer() {
+7 -4
View File
@@ -90,20 +90,23 @@ function closeDialCodes() {
// Apply or remove the trippy-mode class on <body>. Server tells us when to flip via
// 'trippy_status' broadcasts; new joiners get the current value in the 'users' message.
// We always track the server truth in state.* but only render the effect when NOT in
// car mode - so toggling car mode off later re-applies whatever's currently active.
function setTrippyMode(enabled) {
state.trippyMode = !!enabled;
document.body.classList.toggle('trippy-mode', state.trippyMode);
document.body.classList.toggle('trippy-mode', state.trippyMode && !state.settings.carMode);
}
function setSchizoMode(enabled) {
state.schizoMode = !!enabled;
document.body.classList.toggle('schizo-mode', state.schizoMode);
document.body.classList.toggle('schizo-mode', state.schizoMode && !state.settings.carMode);
}
function setPongMode(enabled) {
state.pongMode = !!enabled;
document.body.classList.toggle('pong-mode', state.pongMode);
if (state.pongMode) startPong();
const on = state.pongMode && !state.settings.carMode;
document.body.classList.toggle('pong-mode', on);
if (on) startPong();
else stopPong();
}
+8
View File
@@ -56,6 +56,11 @@ async function toggleCam() {
state.users['local'].camOn = true;
send({ type: 'camera_status', enabled: true });
// Apply the low-bandwidth outgoing bitrate ceiling to the fresh video senders,
// and pause them for any peer that's in car mode (audio-only).
if (typeof applyVideoBitrateCap === 'function') applyVideoBitrateCap();
if (typeof applyAudioOnlyGatingAll === 'function') applyAudioOnlyGatingAll();
} catch (err) {
console.error('Camera error:', err);
alert('Could not access camera: ' + err.message);
@@ -142,6 +147,9 @@ async function toggleScreen() {
state.users['local'].screenOn = true;
send({ type: 'screen_status', enabled: true });
// Pause the fresh screen senders for any peer in car mode (audio-only).
if (typeof applyAudioOnlyGatingAll === 'function') applyAudioOnlyGatingAll();
} catch (err) {
console.error('Screen share error:', err);
// User cancelled or error - don't show alert for user cancellation
+2 -1
View File
@@ -83,7 +83,8 @@ function getSoundContext() {
const SOUND_FILES = {
join: '/static/sounds/gta.wav',
leave: '/static/sounds/htp.wav',
knock: '/static/sounds/knock.mp3'
knock: '/static/sounds/knock.mp3',
laugh: '/static/sounds/laugh.mp3'
};
const soundElements = {};
+88 -6
View File
@@ -301,7 +301,7 @@ async function applySettings() {
if (newMicId && newMicId !== selectedMicId) {
try {
const newAudioStream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: newMicId } }
audio: getAudioConstraints(newMicId, 'exact')
});
const newAudioTrack = newAudioStream.getAudioTracks()[0];
@@ -329,6 +329,12 @@ async function applySettings() {
// (it's reused across peers and across mic switches).
setupLocalAudioAnalyser();
// If the voice-changer graph is active, rebuild its source onto the new mic
// track, then push the correct outgoing track (processed or raw) to every
// sender via the normal gating path. No-op when the graph is inactive.
if (typeof rebuildVoiceFxSource === 'function') rebuildVoiceFxSource();
if (typeof applyBreakoutGatingAll === 'function') applyBreakoutGatingAll();
console.log('[Settings] Microphone switched');
} catch (e) {
console.error('[Settings] Failed to switch microphone:', e);
@@ -379,6 +385,9 @@ async function applySettings() {
$('cam-btn').classList.add('active');
}
applyVideoBitrateCap();
if (typeof applyAudioOnlyGatingAll === 'function') applyAudioOnlyGatingAll();
console.log('[Settings] Camera switched');
} catch (e) {
console.error('[Settings] Failed to switch camera:', e);
@@ -436,6 +445,12 @@ function updateSettingsToggles() {
if (soundToggle) soundToggle.dataset.enabled = state.settings.sounds;
if (lowBwToggle) lowBwToggle.dataset.enabled = state.settings.lowBandwidth;
if (speakerToggle) speakerToggle.dataset.enabled = state.settings.speakerMode !== false;
const nsToggle = $('toggle-noisesuppression');
if (nsToggle) nsToggle.dataset.enabled = state.settings.noiseSuppression !== false;
const carToggle = $('toggle-carmode');
if (carToggle) carToggle.dataset.enabled = !!state.settings.carMode;
}
// Toggle button click handler
@@ -460,13 +475,35 @@ function handleToggleClick(toggleId, settingKey) {
saveSettings();
}
// Get video constraints based on low bandwidth mode
// Build getUserMedia audio constraints: optional mic device + noise-suppression flags.
// mode 'ideal' for initial capture (soft preference), 'exact' for explicit device switches.
function getAudioConstraints(deviceId, mode) {
const ns = state.settings.noiseSuppression !== false;
const c = { echoCancellation: ns, noiseSuppression: ns, autoGainControl: ns };
if (deviceId) c.deviceId = (mode === 'exact') ? { exact: deviceId } : { ideal: deviceId };
return c;
}
// Apply the current noise-suppression setting to the live mic track without re-acquiring
// it. Works whether or not the voice-changer graph is active, since the constraint lives
// on the raw mic track that feeds the graph.
function applyNoiseSuppression() {
const track = state.localStream?.getAudioTracks()[0];
if (!track) return;
const ns = state.settings.noiseSuppression !== false;
track.applyConstraints({ echoCancellation: ns, noiseSuppression: ns, autoGainControl: ns })
.then(() => console.log('[Audio] Noise suppression', ns ? 'on' : 'off'))
.catch(e => console.warn('[Audio] applyConstraints failed:', e?.message || e));
}
// Get video constraints based on low bandwidth mode. Low mode hard-caps resolution and
// framerate (max, not just ideal) so the encoder can't drift back up on a good moment.
function getVideoConstraints() {
if (state.settings.lowBandwidth) {
return {
width: { ideal: 640 },
height: { ideal: 360 },
frameRate: { ideal: 15, max: 15 }
width: { ideal: 640, max: 640 },
height: { ideal: 360, max: 360 },
frameRate: { ideal: 15, max: 15 }
};
}
return {
@@ -475,6 +512,40 @@ function getVideoConstraints() {
};
}
// Low-bandwidth outgoing CAMERA bitrate ceiling (bits/sec). Screen share is left uncapped
// so shared text stays legible.
const LOWBW_VIDEO_BITRATE = 300000;
// Cap (or uncap) the outgoing camera bitrate on every peer's video sender. setParameters
// is transparent (no renegotiation) and safe to call repeatedly; screen-share senders
// (peer.screenSender) are skipped. Called on toggle and whenever a camera sender is
// (re)created or a peer connects.
function applyVideoBitrateCap() {
const cap = state.settings.lowBandwidth ? LOWBW_VIDEO_BITRATE : undefined;
Object.values(state.peers).forEach(peer => {
if (!peer.pc) return;
peer.pc.getSenders().forEach(sender => {
if (!sender.track || sender.track.kind !== 'video') return;
if (peer.screenSender && sender === peer.screenSender) return;
const params = sender.getParameters();
if (!params.encodings || !params.encodings.length) params.encodings = [{}];
params.encodings[0].maxBitrate = cap;
sender.setParameters(params).catch(e => console.warn('[LowBW] setParameters failed:', e?.message || e));
});
});
}
// Apply low-bandwidth mode to live video: shrink the current camera track in place (no
// renegotiation) and (re)apply the outgoing bitrate ceiling.
function applyLowBandwidth() {
const videoTrack = state.localStream?.getVideoTracks()[0];
if (videoTrack) {
videoTrack.applyConstraints(getVideoConstraints())
.catch(e => console.warn('[LowBW] applyConstraints failed:', e?.message || e));
}
applyVideoBitrateCap();
}
// Settings modal event listeners
document.addEventListener('DOMContentLoaded', () => {
$('settings-close')?.addEventListener('click', closeSettings);
@@ -483,7 +554,18 @@ document.addEventListener('DOMContentLoaded', () => {
// Toggle button listeners
$('toggle-notifications')?.addEventListener('click', () => handleToggleClick('toggle-notifications', 'notifications'));
$('toggle-sounds')?.addEventListener('click', () => handleToggleClick('toggle-sounds', 'sounds'));
$('toggle-lowbandwidth')?.addEventListener('click', () => handleToggleClick('toggle-lowbandwidth', 'lowBandwidth'));
$('toggle-lowbandwidth')?.addEventListener('click', () => {
handleToggleClick('toggle-lowbandwidth', 'lowBandwidth');
applyLowBandwidth();
});
$('toggle-carmode')?.addEventListener('click', () => {
handleToggleClick('toggle-carmode', 'carMode');
if (typeof applyCarMode === 'function') applyCarMode();
});
$('toggle-noisesuppression')?.addEventListener('click', () => {
handleToggleClick('toggle-noisesuppression', 'noiseSuppression');
applyNoiseSuppression();
});
$('toggle-speakermode')?.addEventListener('click', () => {
handleToggleClick('toggle-speakermode', 'speakerMode');
if (typeof rebuildAudioSinksForSpeakerMode === 'function') rebuildAudioSinksForSpeakerMode();
+7
View File
@@ -49,6 +49,13 @@ const state = {
notifications: true,
sounds: true,
lowBandwidth: false,
// Browser-native mic noise suppression + echo cancel + auto-gain. Applied as
// getUserMedia constraints and live via track.applyConstraints when toggled.
noiseSuppression: true,
// Car mode: audio-only. Hides all video/screen locally, tells peers to stop
// sending us video, suppresses visual dial effects + their sounds, and drops
// our outgoing audio to a low bitrate. For driving / very constrained links.
carMode: false,
// Mobile audio routing. true = remote audio plays through a hidden <video>
// element, which iOS classifies as media playback (loudspeaker). false = plays
// through <audio>, which under an active mic becomes communication category
+10
View File
@@ -29,6 +29,16 @@ function updateVideoGrid() {
const maxView = $('maximized-video');
const thumbStrip = $('thumbnail-strip');
// Car mode: audio-only. Render no video tiles at all. Incoming video is also paused
// at every sender (car mode broadcasts audio_only), so there's nothing to show.
if (state.settings.carMode) {
state.maximizedPeer = null;
grid.innerHTML = '';
$('maximized-view')?.classList.add('hidden');
grid.classList.remove('hidden');
return;
}
const camUsers = [];
// Add local camera if enabled
+7
View File
@@ -382,6 +382,13 @@ async function createPeerConnection(peerId, username, initiator) {
// Reset ICE restart counter on successful connection
if (state.peers[peerId]) state.peers[peerId].iceRestartCount = 0;
startNetworkMonitoring(peerId);
// (Re)apply outgoing shaping now that this peer's senders exist and
// setParameters will succeed. Covers newly-joined peers: low-bandwidth video
// cap, car-mode video pausing (if this peer is audio-only), and our own
// car-mode audio bitrate cap.
if (typeof applyVideoBitrateCap === 'function') applyVideoBitrateCap();
if (typeof applyAudioOnlyGatingForPeer === 'function') applyAudioOnlyGatingForPeer(peerId);
if (typeof applyCarModeAudioBitrate === 'function') applyCarModeAudioBitrate();
updateUI();
} else if (pc.connectionState === 'failed') {
+67
View File
@@ -2395,3 +2395,70 @@ body.pong-mode.schizo-mode .video-tile video {
letter-spacing: 0.1em;
vertical-align: middle;
}
/* ========== VOICE CHANGER MODAL ========== */
.vc-modal {
max-width: 420px;
}
.vc-body {
display: flex;
flex-direction: column;
gap: 0.85rem;
padding: 1rem 0 0.25rem;
}
.vc-row-toggles {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.vc-toggle-item {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.vc-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.vc-preset {
padding: 0.5rem 0.25rem;
font-size: 0.8rem;
}
.vc-slider {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vc-slider-label {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-secondary);
}
.vc-slider-label em {
font-style: normal;
color: var(--acid);
font-family: 'JetBrains Mono', monospace;
}
.vc-slider input[type="range"] {
width: 100%;
accent-color: var(--acid);
}
.vc-telephone-row {
justify-content: space-between;
border-top: 1px solid var(--border);
padding-top: 0.75rem;
}