fixed breakout room issue and added noise cancelling and car mode
This commit is contained in:
@@ -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':
|
||||
|
||||
@@ -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 — 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 — 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user