Skip to content

Commit b714f3b

Browse files
committed
feat: implement audio mixer hook to combine host and viewer audio streams release:patch
1 parent 1c27bea commit b714f3b

5 files changed

Lines changed: 419 additions & 9 deletions

File tree

apps/desktop/electron-builder.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ win:
4848
arch:
4949
- x64
5050
- arm64
51-
signingHashAlgorithms:
52-
- sha256
5351

5452
linux:
5553
target:

apps/desktop/src/renderer/components/capture/CapturePreview.tsx

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useRecording, formatDuration, type RecordingQuality } from '@/hooks/use
3535
import { useRTMPStreaming } from '@/hooks/useRTMPStreaming';
3636
import { useWebRTCHostAPI } from '@/hooks/useWebRTCHostAPI';
3737
import { useWebRTCHostSFUAPI } from '@/hooks/useWebRTCHostSFUAPI';
38+
import { useAudioMixer } from '@/hooks/useAudioMixer';
3839
import {
3940
SharingIndicator,
4041
RecordingIndicator,
@@ -150,6 +151,7 @@ export function CapturePreview({
150151
const {
151152
isHosting,
152153
viewerCount,
154+
viewers: hostedViewers,
153155
error: hostingError,
154156
startHosting,
155157
stopHosting,
@@ -175,6 +177,57 @@ export function CapturePreview({
175177
},
176178
});
177179

180+
// Audio mixer: combines host mic + all viewer audio into one stream for recording
181+
const {
182+
mixedStream,
183+
addTrack: mixerAddTrack,
184+
removeTrack: mixerRemoveTrack,
185+
setTrackMuted: mixerSetTrackMuted,
186+
dispose: disposeMixer,
187+
} = useAudioMixer();
188+
189+
// Add host mic audio to the mixer
190+
useEffect(() => {
191+
const audioTracks = stream.getAudioTracks();
192+
if (audioTracks.length > 0) {
193+
mixerAddTrack('host-mic', audioTracks[0]);
194+
}
195+
return () => {
196+
mixerRemoveTrack('host-mic');
197+
};
198+
}, [stream, mixerAddTrack, mixerRemoveTrack]);
199+
200+
// Sync viewer audio tracks into the mixer as viewers join/leave
201+
useEffect(() => {
202+
const currentViewerIds = new Set<string>();
203+
204+
for (const [viewerId, viewer] of hostedViewers.entries()) {
205+
if (viewer.audioTrack) {
206+
currentViewerIds.add(viewerId);
207+
mixerAddTrack(viewerId, viewer.audioTrack);
208+
mixerSetTrackMuted(viewerId, viewer.isMuted);
209+
}
210+
}
211+
212+
// Remove tracks for viewers no longer present
213+
// (handled automatically by useAudioMixer when viewer is removed from the map,
214+
// but we also clean up explicitly for tracks that disappeared)
215+
return () => {
216+
for (const viewerId of currentViewerIds) {
217+
if (!hostedViewers.has(viewerId)) {
218+
mixerRemoveTrack(viewerId);
219+
}
220+
}
221+
};
222+
}, [hostedViewers, mixerAddTrack, mixerRemoveTrack, mixerSetTrackMuted]);
223+
224+
// Clean up mixer when component unmounts
225+
useEffect(() => {
226+
return () => {
227+
disposeMixer();
228+
};
229+
}, [disposeMixer]);
230+
178231
// Start WebRTC hosting when session is created
179232
useEffect(() => {
180233
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -369,15 +422,33 @@ export function CapturePreview({
369422
const handleStartRecording = useCallback(async () => {
370423
if (!source) return;
371424
setSpaceWarning(null);
425+
426+
// Build a recording stream that includes video + mixed audio (host mic + viewer audio).
427+
// The mixer's output is a live AudioContext graph, so viewers joining/leaving during
428+
// recording are automatically included without needing to restart MediaRecorder.
429+
let recordingStream: MediaStream;
430+
if (includeAudio && mixedStream) {
431+
recordingStream = new MediaStream();
432+
// Video from the screen capture
433+
stream.getVideoTracks().forEach((track) => {
434+
recordingStream.addTrack(track);
435+
});
436+
// Audio from the mixer (host mic + all viewer audio combined)
437+
mixedStream.getAudioTracks().forEach((track) => {
438+
recordingStream.addTrack(track);
439+
});
440+
} else {
441+
recordingStream = stream;
442+
}
443+
372444
await startRecording(source.id, {
373445
quality: recordingQuality,
374446
format: 'webm',
375447
includeAudio,
376-
// Pass the existing stream so we don't need to create a new one
377-
// This is especially important for Wayland where the source ID isn't a valid chromeMediaSourceId
378-
existingStream: stream,
448+
// Pass the combined stream — Wayland-safe and includes all participant audio
449+
existingStream: recordingStream,
379450
});
380-
}, [source, recordingQuality, includeAudio, startRecording, stream]);
451+
}, [source, recordingQuality, includeAudio, startRecording, stream, mixedStream]);
381452

382453
const handleStopRecording = useCallback(async () => {
383454
await stopRecording();
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Audio mixer hook using Web Audio API
3+
*
4+
* Combines multiple audio tracks (host mic + remote viewer audio) into a single
5+
* MediaStream output suitable for recording via MediaRecorder. Sources can be
6+
* added and removed dynamically — the AudioContext graph is live, so the
7+
* MediaRecorder automatically picks up changes.
8+
*/
9+
10+
import { useCallback, useEffect, useRef, useState } from 'react';
11+
12+
interface AudioSource {
13+
id: string;
14+
sourceNode: MediaStreamAudioSourceNode;
15+
gainNode: GainNode;
16+
}
17+
18+
export function useAudioMixer() {
19+
const audioContextRef = useRef<AudioContext | null>(null);
20+
const destinationRef = useRef<MediaStreamAudioDestinationNode | null>(null);
21+
const sourcesRef = useRef<Map<string, AudioSource>>(new Map());
22+
const [mixedStream, setMixedStream] = useState<MediaStream | null>(null);
23+
24+
// Lazily create AudioContext + destination on first use
25+
const ensureContext = useCallback((): {
26+
ctx: AudioContext;
27+
destination: MediaStreamAudioDestinationNode;
28+
} | null => {
29+
if (audioContextRef.current && destinationRef.current) {
30+
return { ctx: audioContextRef.current, destination: destinationRef.current };
31+
}
32+
33+
try {
34+
const ctx = new AudioContext();
35+
const destination = ctx.createMediaStreamDestination();
36+
37+
audioContextRef.current = ctx;
38+
destinationRef.current = destination;
39+
setMixedStream(destination.stream);
40+
41+
console.log('[AudioMixer] Initialized AudioContext');
42+
return { ctx, destination };
43+
} catch (err) {
44+
console.error('[AudioMixer] Failed to create AudioContext:', err);
45+
return null;
46+
}
47+
}, []);
48+
49+
// Add an audio track to the mix
50+
const addTrack = useCallback(
51+
(id: string, track: MediaStreamTrack) => {
52+
if (sourcesRef.current.has(id)) return;
53+
54+
const result = ensureContext();
55+
if (!result) return;
56+
const { ctx, destination } = result;
57+
58+
// Resume context if suspended (e.g. autoplay policy)
59+
if (ctx.state === 'suspended') {
60+
void ctx.resume();
61+
}
62+
63+
const sourceNode = ctx.createMediaStreamSource(new MediaStream([track]));
64+
const gainNode = ctx.createGain();
65+
gainNode.gain.value = 1.0;
66+
67+
sourceNode.connect(gainNode);
68+
gainNode.connect(destination);
69+
70+
sourcesRef.current.set(id, { id, sourceNode, gainNode });
71+
console.log(`[AudioMixer] Added track: ${id} (${String(sourcesRef.current.size)} sources)`);
72+
},
73+
[ensureContext]
74+
);
75+
76+
// Remove an audio track from the mix
77+
const removeTrack = useCallback((id: string) => {
78+
const source = sourcesRef.current.get(id);
79+
if (!source) return;
80+
81+
try {
82+
source.gainNode.disconnect();
83+
source.sourceNode.disconnect();
84+
} catch {
85+
// Already disconnected
86+
}
87+
88+
sourcesRef.current.delete(id);
89+
console.log(`[AudioMixer] Removed track: ${id} (${String(sourcesRef.current.size)} sources)`);
90+
}, []);
91+
92+
// Mute/unmute a specific track (sets gain to 0 or 1)
93+
const setTrackMuted = useCallback((id: string, muted: boolean) => {
94+
const source = sourcesRef.current.get(id);
95+
if (source) {
96+
source.gainNode.gain.value = muted ? 0 : 1;
97+
}
98+
}, []);
99+
100+
// Tear down the mixer
101+
const dispose = useCallback(() => {
102+
for (const source of sourcesRef.current.values()) {
103+
try {
104+
source.gainNode.disconnect();
105+
source.sourceNode.disconnect();
106+
} catch {
107+
// Already disconnected
108+
}
109+
}
110+
sourcesRef.current.clear();
111+
112+
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
113+
void audioContextRef.current.close();
114+
}
115+
audioContextRef.current = null;
116+
destinationRef.current = null;
117+
setMixedStream(null);
118+
119+
console.log('[AudioMixer] Disposed');
120+
}, []);
121+
122+
// Clean up on unmount
123+
useEffect(() => {
124+
return () => {
125+
dispose();
126+
};
127+
}, [dispose]);
128+
129+
return {
130+
/** The output MediaStream containing mixed audio from all added tracks */
131+
mixedStream,
132+
addTrack,
133+
removeTrack,
134+
setTrackMuted,
135+
dispose,
136+
};
137+
}

apps/web/src/app/host/[id]/page.tsx

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { useScreenCapture, type CaptureQuality } from '@/hooks/useScreenCapture'
2828
import { useRecording, formatDuration, type RecordingQuality } from '@/hooks/useRecording';
2929
import { useWebRTCHost } from '@/hooks/useWebRTCHost';
3030
import { useWebRTCHostSFU } from '@/hooks/useWebRTCHostSFU';
31+
import { useAudioMixer } from '@/hooks/useAudioMixer';
3132
import type { SessionParticipant } from '@pairux/shared-types';
3233
import { Logo } from '@/components/Logo';
3334

@@ -51,6 +52,12 @@ interface ApiResponse<T> {
5152
error?: string;
5253
}
5354

55+
// Minimal viewer info needed for audio mixing
56+
interface HostedViewer {
57+
audioTrack: MediaStreamTrack | null;
58+
isMuted: boolean;
59+
}
60+
5461
// Common type for both P2P and SFU host hooks (subset used by this page)
5562
type HostHookFn = (options: {
5663
sessionId: string;
@@ -61,6 +68,7 @@ type HostHookFn = (options: {
6168
}) => {
6269
isHosting: boolean;
6370
viewerCount: number;
71+
viewers: Map<string, HostedViewer>;
6472
error: string | null;
6573
startHosting: () => Promise<void>;
6674
stopHosting: () => void;
@@ -205,6 +213,7 @@ function HostContent({
205213
const {
206214
isHosting,
207215
viewerCount,
216+
viewers: hostedViewers,
208217
error: hostingError,
209218
startHosting,
210219
stopHosting,
@@ -224,6 +233,56 @@ function HostContent({
224233
},
225234
});
226235

236+
// Audio mixer: combines host mic + all viewer audio into one stream for recording
237+
const {
238+
mixedStream,
239+
addTrack: mixerAddTrack,
240+
removeTrack: mixerRemoveTrack,
241+
setTrackMuted: mixerSetTrackMuted,
242+
dispose: disposeMixer,
243+
} = useAudioMixer();
244+
245+
// Add host mic audio to the mixer
246+
useEffect(() => {
247+
if (micStream) {
248+
const firstAudioTrack = micStream.getAudioTracks()[0];
249+
if (firstAudioTrack) {
250+
mixerAddTrack('host-mic', firstAudioTrack);
251+
}
252+
}
253+
return () => {
254+
mixerRemoveTrack('host-mic');
255+
};
256+
}, [micStream, mixerAddTrack, mixerRemoveTrack]);
257+
258+
// Sync viewer audio tracks into the mixer as viewers join/leave
259+
useEffect(() => {
260+
const currentViewerIds = new Set<string>();
261+
262+
for (const [viewerId, viewer] of hostedViewers.entries()) {
263+
if (viewer.audioTrack) {
264+
currentViewerIds.add(viewerId);
265+
mixerAddTrack(viewerId, viewer.audioTrack);
266+
mixerSetTrackMuted(viewerId, viewer.isMuted);
267+
}
268+
}
269+
270+
return () => {
271+
for (const viewerId of currentViewerIds) {
272+
if (!hostedViewers.has(viewerId)) {
273+
mixerRemoveTrack(viewerId);
274+
}
275+
}
276+
};
277+
}, [hostedViewers, mixerAddTrack, mixerRemoveTrack, mixerSetTrackMuted]);
278+
279+
// Clean up mixer when component unmounts
280+
useEffect(() => {
281+
return () => {
282+
disposeMixer();
283+
};
284+
}, [disposeMixer]);
285+
227286
// Start hosting when stream is available
228287
useEffect(() => {
229288
if (stream && !isHosting) {
@@ -266,19 +325,27 @@ function HostContent({
266325
if (!stream) return;
267326
setRecordingBlob(null);
268327

269-
// Create combined stream: screen video + mic audio
328+
// Build a recording stream that includes video + mixed audio (host mic + viewer audio).
329+
// The mixer's output is a live AudioContext graph, so viewers joining/leaving during
330+
// recording are automatically included without needing to restart MediaRecorder.
270331
const combinedStream = new MediaStream();
271332
stream.getVideoTracks().forEach((track) => {
272333
combinedStream.addTrack(track);
273334
});
274-
if (micStream) {
335+
if (mixedStream) {
336+
// Use mixer output (host mic + all viewer audio combined)
337+
mixedStream.getAudioTracks().forEach((track) => {
338+
combinedStream.addTrack(track);
339+
});
340+
} else if (micStream) {
341+
// Fallback: mixer not ready, use raw mic only
275342
micStream.getAudioTracks().forEach((track) => {
276343
combinedStream.addTrack(track);
277344
});
278345
}
279346

280347
startRecording(combinedStream, { quality: recordingQuality });
281-
}, [stream, micStream, recordingQuality, startRecording]);
348+
}, [stream, micStream, mixedStream, recordingQuality, startRecording]);
282349

283350
// Handle stop recording
284351
const handleStopRecording = useCallback(() => {

0 commit comments

Comments
 (0)