From cfc5e3674fe0bb739e657750a8c912ff13dc19f1 Mon Sep 17 00:00:00 2001 From: Adam Fraser Date: Sun, 1 Mar 2026 08:33:41 -0500 Subject: [PATCH] Add stereo support --- client/src/App.css | 39 ++++++ client/src/components/AudioVisualizer.jsx | 156 ++++++++++++++-------- client/src/components/HostView.jsx | 73 +++++++++- client/src/components/PeerView.jsx | 7 +- 4 files changed, 211 insertions(+), 64 deletions(-) diff --git a/client/src/App.css b/client/src/App.css index ad950b6..b433c61 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -459,6 +459,45 @@ input[type='range']::-webkit-slider-thumb:hover { transform: scale(1.3); } line-height: 1.5; } +/* ── Quality toggle ─────────────────────────────────────────────────────────── */ + +.quality-toggle { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; + flex-wrap: wrap; +} + +.quality-toggle-label { + font-size: 13px; + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; +} + +.quality-toggle-btns { + display: flex; +} + +.quality-toggle-btns .btn { + padding: 6px 14px; + font-size: 13px; + border-radius: 0; +} + +.quality-toggle-btns .btn:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); +} + +.quality-toggle-btns .btn:last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; +} + +.quality-toggle .hint { + margin-top: 0; +} + /* ── Back link ───────────────────────────────────────────────────────────────── */ .back-btn { diff --git a/client/src/components/AudioVisualizer.jsx b/client/src/components/AudioVisualizer.jsx index e16ff86..db089f7 100644 --- a/client/src/components/AudioVisualizer.jsx +++ b/client/src/components/AudioVisualizer.jsx @@ -4,100 +4,138 @@ * Renders a real-time waveform of the provided MediaStream using * the Web Audio API AnalyserNode + a element. * + * Stereo streams are split via ChannelSplitterNode and drawn as two + * overlaid waveforms — purple (left) and green (right). + * Mono streams fall back to a single waveform using the `color` prop. + * * Props: * stream — MediaStream (required) - * color — CSS color string (optional, defaults to accent purple) - * height — canvas height in px (optional, defaults to 72) + * color — CSS color string, used for mono fallback (default: accent purple) + * height — canvas height in px (default: 72) + * stereo — boolean | null; explicit override (null = auto-detect from channelCount) */ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef } from "react"; + +const LEFT_COLOR = "#9d97ff"; // purple — left channel +const RIGHT_COLOR = "#34d399"; // green — right channel -export default function AudioVisualizer({ stream, color = '#9d97ff', height = 72 }) { +function makeAnalyser(audioCtx) { + const a = audioCtx.createAnalyser(); + a.fftSize = 1024; + a.smoothingTimeConstant = 0.8; + return a; +} + +export default function AudioVisualizer({ + stream, + color = "#9d97ff", + height = 72, + stereo: stereoProp = null, +}) { const canvasRef = useRef(null); - const rafRef = useRef(null); - const ctxRef = useRef(null); // AudioContext - const analyserRef = useRef(null); + const rafRef = useRef(null); + const ctxRef = useRef(null); useEffect(() => { if (!stream) return; const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - const analyser = audioCtx.createAnalyser(); - analyser.fftSize = 1024; - analyser.smoothingTimeConstant = 0.8; - const source = audioCtx.createMediaStreamSource(stream); - source.connect(analyser); - // NOTE: we intentionally do NOT connect to audioCtx.destination here — - // the HostView stream is already being sent over WebRTC and the - // PeerView already routes through its own AudioContext. + const stereo = stereoProp !== null ? stereoProp : source.channelCount >= 2; + + let analyserL, analyserR; + if (stereo) { + const splitter = audioCtx.createChannelSplitter(2); + source.connect(splitter); + analyserL = makeAnalyser(audioCtx); + analyserR = makeAnalyser(audioCtx); + splitter.connect(analyserL, 0); // left → analyserL + splitter.connect(analyserR, 1); // right → analyserR + } else { + analyserL = makeAnalyser(audioCtx); + source.connect(analyserL); + } ctxRef.current = audioCtx; - analyserRef.current = analyser; const canvas = canvasRef.current; - const ctx2d = canvas.getContext('2d'); - const bufferLength = analyser.frequencyBinCount; - const dataArray = new Float32Array(bufferLength); - - function draw() { - rafRef.current = requestAnimationFrame(draw); - analyser.getFloatTimeDomainData(dataArray); + const ctx2d = canvas.getContext("2d"); + const bufLen = analyserL.frequencyBinCount; + const dataL = new Float32Array(bufLen); + const dataR = stereo ? new Float32Array(bufLen) : null; + function drawChannel(data, waveColor, yOffset) { const { width, height: h } = canvas; - ctx2d.clearRect(0, 0, width, h); + const sliceWidth = width / bufLen; - // Background - ctx2d.fillStyle = 'rgba(21, 24, 32, 0.0)'; - ctx2d.fillRect(0, 0, width, h); - - // Centre line - ctx2d.strokeStyle = 'rgba(255,255,255,0.05)'; - ctx2d.lineWidth = 1; + // Waveform line ctx2d.beginPath(); - ctx2d.moveTo(0, h / 2); - ctx2d.lineTo(width, h / 2); - ctx2d.stroke(); - - // Waveform - ctx2d.beginPath(); - ctx2d.lineWidth = 2; - ctx2d.strokeStyle = color; + ctx2d.lineWidth = stereo ? 1.5 : 2; + ctx2d.strokeStyle = waveColor; ctx2d.shadowBlur = 8; - ctx2d.shadowColor = color; + ctx2d.shadowColor = waveColor; - const sliceWidth = width / bufferLength; let x = 0; - - for (let i = 0; i < bufferLength; i++) { - const v = dataArray[i]; - const y = (v + 1) / 2 * h; // map [-1,1] → [h,0] + for (let i = 0; i < bufLen; i++) { + const y = (data[i] + 1) * yOffset * h; if (i === 0) ctx2d.moveTo(x, y); - else ctx2d.lineTo(x, y); + else ctx2d.lineTo(x, y); x += sliceWidth; } - ctx2d.lineTo(width, h / 2); ctx2d.stroke(); - // Glow fill underneath the waveform + // Glow fill underneath ctx2d.shadowBlur = 0; ctx2d.beginPath(); x = 0; - for (let i = 0; i < bufferLength; i++) { - const v = dataArray[i]; - const y = (v + 1) / 2 * h; + for (let i = 0; i < bufLen; i++) { + const y = ((data[i] + 1) / 2) * h; if (i === 0) ctx2d.moveTo(x, y); - else ctx2d.lineTo(x, y); + else ctx2d.lineTo(x, y); x += sliceWidth; } ctx2d.lineTo(width, h / 2); ctx2d.lineTo(0, h / 2); ctx2d.closePath(); - ctx2d.fillStyle = `${color}22`; + ctx2d.fillStyle = `${waveColor}22`; ctx2d.fill(); } + function draw() { + rafRef.current = requestAnimationFrame(draw); + + analyserL.getFloatTimeDomainData(dataL); + if (dataR) analyserR.getFloatTimeDomainData(dataR); + + const { width, height: h } = canvas; + ctx2d.clearRect(0, 0, width, h); + + // Centre line + ctx2d.strokeStyle = "rgba(255,255,255,0.05)"; + ctx2d.lineWidth = 1; + ctx2d.beginPath(); + ctx2d.moveTo(0, h / 2); + ctx2d.lineTo(width, h / 2); + ctx2d.stroke(); + + if (stereo) { + drawChannel(dataL, LEFT_COLOR, 0.333); + drawChannel(dataR, RIGHT_COLOR, 0.666); + + // Subtle L / R labels + ctx2d.shadowBlur = 0; + ctx2d.font = "500 10px monospace"; + ctx2d.fillStyle = LEFT_COLOR + "99"; + ctx2d.fillText("L", 6, 13); + ctx2d.fillStyle = RIGHT_COLOR + "99"; + ctx2d.fillText("R", 18, 13); + } else { + drawChannel(dataL, color, 0.5); + } + } + draw(); return () => { @@ -105,7 +143,7 @@ export default function AudioVisualizer({ stream, color = '#9d97ff', height = 72 source.disconnect(); audioCtx.close(); }; - }, [stream, color]); + }, [stream, color, stereoProp]); return ( ); diff --git a/client/src/components/HostView.jsx b/client/src/components/HostView.jsx index fc8ca02..467be21 100644 --- a/client/src/components/HostView.jsx +++ b/client/src/components/HostView.jsx @@ -14,6 +14,26 @@ import { useState, useCallback, useRef, useEffect } from "react"; import { useSignaling } from "../hooks/useSignaling.js"; import AudioVisualizer from "./AudioVisualizer.jsx"; +/** + * Patch the Opus fmtp line in an SDP string to enable or disable stereo. + * + * WebRTC Opus defaults to mono. Adding stereo=1;sprop-stereo=1 tells the + * codec to encode and send a 2-channel stream. Stereo roughly doubles the + * audio bitrate (Opus uses mid-side encoding so it's ~1.5–2× mono, not + * exactly 2×, depending on how much stereo separation the content has). + */ +function patchOpusStereo(sdp, stereo) { + const opusPt = sdp.match(/a=rtpmap:(\d+) opus\/48000/i)?.[1]; + if (!opusPt) return sdp; + const flag = stereo ? 1 : 0; + return sdp.replace(new RegExp(`(a=fmtp:${opusPt} [^\r\n]*)`), (match) => { + const cleaned = match + .replace(/;?stereo=\d/, "") + .replace(/;?sprop-stereo=\d/, ""); + return `${cleaned};stereo=${flag};sprop-stereo=${flag}`; + }); +} + // Google's public STUN servers — good enough for most NAT configurations const ICE_SERVERS = [ { urls: "stun:stun.l.google.com:19302" }, @@ -30,10 +50,15 @@ export default function HostView({ onBack }) { const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [isCapturing, setIsCapturing] = useState(false); + const [stereo, setStereo] = useState(true); // default stereo for music // We store peer connections in a ref so signaling callbacks see fresh state const peerConnsRef = useRef({}); const streamRef = useRef(null); + const stereoRef = useRef(stereo); + useEffect(() => { + stereoRef.current = stereo; + }, [stereo]); // ── Signaling message handler ────────────────────────────────────────────── @@ -121,6 +146,9 @@ export default function HostView({ onBack }) { }, }); + const track = mediaStream.getAudioTracks()[0]; + console.log("[capture]", track.getSettings()); + // Handle the user dismissing the picker without choosing mediaStream.getAudioTracks()[0]?.addEventListener("ended", stopCapture); @@ -184,6 +212,18 @@ export default function HostView({ onBack }) { [peerId]: { ...prev[peerId], state: pc.connectionState }, })); + if (pc.connectionState === "connected") { + // Push the Opus encoder to 320 kbps so the mid-side Side channel + // gets enough bits to preserve stereo width. + const sender = pc.getSenders().find((s) => s.track?.kind === "audio"); + if (sender) { + const params = sender.getParameters(); + if (!params.encodings.length) params.encodings = [{}]; + params.encodings[0].maxBitrate = 320_000; + sender.setParameters(params).catch(console.error); + } + } + if ( pc.connectionState === "failed" || pc.connectionState === "closed" @@ -196,9 +236,10 @@ export default function HostView({ onBack }) { peerConnsRef.current[peerId] = { pc }; setPeers((prev) => ({ ...prev, [peerId]: { state: "connecting" } })); - // Create and send the offer + // Create and send the offer, patching Opus for stereo if enabled const offer = await pc.createOffer(); - await pc.setLocalDescription(offer); + const patchedSdp = patchOpusStereo(offer.sdp, stereoRef.current); + await pc.setLocalDescription({ type: offer.type, sdp: patchedSdp }); send({ type: "offer", sdp: pc.localDescription, targetPeerId: peerId }); // eslint-disable-next-line react-hooks/exhaustive-deps }, @@ -289,6 +330,30 @@ export default function HostView({ onBack }) {
Audio Source
+ {/* Mono / Stereo toggle — locked once capturing starts */} +
+ Channel +
+ + +
+ + {stereo ? "~2× bandwidth, better for music" : "lower bandwidth"} + +
+ {!isCapturing ? ( <>
- + )} diff --git a/client/src/components/PeerView.jsx b/client/src/components/PeerView.jsx index 0d28041..2738465 100644 --- a/client/src/components/PeerView.jsx +++ b/client/src/components/PeerView.jsx @@ -29,6 +29,7 @@ export default function PeerView({ onBack, initialRoomCode = null }) { const [volume, setVolume] = useState(80); const [error, setError] = useState(null); const [audioSuspended, setAudioSuspended] = useState(false); + const [remoteStreamStereo, setRemoteStreamStereo] = useState(false); const pcRef = useRef(null); const gainNodeRef = useRef(null); @@ -129,6 +130,10 @@ export default function PeerView({ onBack, initialRoomCode = null }) { // ── Handle WebRTC offer ──────────────────────────────────────────────────── const handleOffer = useCallback(async (sdp) => { + // Detect whether the host patched the SDP for stereo Opus + const isStereo = /stereo=1/.test(sdp.sdp ?? sdp); + setRemoteStreamStereo(isStereo); + const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS }); pcRef.current = pc; @@ -300,7 +305,7 @@ export default function PeerView({ onBack, initialRoomCode = null }) { 🔊 Click to enable audio ) : ( - + )}