Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions client/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
156 changes: 97 additions & 59 deletions client/src/components/AudioVisualizer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,121 +4,159 @@
* Renders a real-time waveform of the provided MediaStream using
* the Web Audio API AnalyserNode + a <canvas> 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 () => {
cancelAnimationFrame(rafRef.current);
source.disconnect();
audioCtx.close();
};
}, [stream, color]);
}, [stream, color, stereoProp]);

return (
<canvas
ref={canvasRef}
width={512}
height={height}
style={{
width: '100%',
width: "100%",
height: `${height}px`,
borderRadius: '8px',
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
display: 'block',
borderRadius: "8px",
background: "var(--bg-elevated)",
border: "1px solid var(--border)",
display: "block",
}}
/>
);
Expand Down
73 changes: 69 additions & 4 deletions client/src/components/HostView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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 ──────────────────────────────────────────────

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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"
Expand All @@ -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
},
Expand Down Expand Up @@ -289,6 +330,30 @@ export default function HostView({ onBack }) {
<div className="card">
<div className="card-title">Audio Source</div>

{/* Mono / Stereo toggle — locked once capturing starts */}
<div className="quality-toggle">
<span className="quality-toggle-label">Channel</span>
<div className="quality-toggle-btns">
<button
className={`btn ${!stereo ? "btn-primary" : "btn-ghost"}`}
onClick={() => setStereo(false)}
disabled={isCapturing}
>
Mono
</button>
<button
className={`btn ${stereo ? "btn-primary" : "btn-ghost"}`}
onClick={() => setStereo(true)}
disabled={isCapturing}
>
Stereo
</button>
</div>
<span className="hint">
{stereo ? "~2× bandwidth, better for music" : "lower bandwidth"}
</span>
</div>

{!isCapturing ? (
<>
<button
Expand All @@ -312,13 +377,13 @@ export default function HostView({ onBack }) {
style={{ flex: 1, justifyContent: "center", padding: "8px 0" }}
>
<span className="status-dot" />
Streaming audio live
Streaming audio live · {stereo ? "stereo" : "mono"}
</span>
<button className="btn btn-danger" onClick={stopCapture}>
⏹ Stop
</button>
</div>
<AudioVisualizer stream={stream} />
<AudioVisualizer stream={stream} stereo={stereo} />
</>
)}

Expand Down
Loading