Skip to content
Merged
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
11 changes: 2 additions & 9 deletions client/src/components/ControlBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ describe('ControlBar', () => {

const leave = btn('Leave call');
expect(leave).toBeVisible();
expect(screen.getAllByRole('button', { name: 'Leave call' })).toHaveLength(1);
fireEvent.click(leave);
expect(props.onLeave).toHaveBeenCalledTimes(1);
});
Expand All @@ -281,27 +282,19 @@ describe('ControlBar', () => {
expect(within(menu).getByRole('menuitem', { name: /copy meeting link/i })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: /open mini player/i })).toBeInTheDocument();
expect(within(menu).getByRole('menuitem', { name: /sound effects/i })).toHaveTextContent('On');
expect(within(menu).getByRole('menuitem', { name: /leave call/i })).toBeInTheDocument();
expect(within(menu).queryByRole('menuitem', { name: /leave call/i })).not.toBeInTheDocument();

const items = within(menu).getAllByRole('menuitem').map((el) => el.textContent);
expect(items).toEqual([
'Copy meeting link',
'Open mini player',
'Sound effectsOn',
'Leave call',
]);

fireEvent.click(within(menu).getByRole('menuitem', { name: /copy meeting link/i }));
expect(props.onCopyLink).toHaveBeenCalledTimes(1);
});

it('keeps Leave in the overflow menu as a secondary path', () => {
const props = renderBar();

fireEvent.click(btn('More options'));
fireEvent.click(within(screen.getByRole('menu')).getByRole('menuitem', { name: /leave call/i }));
expect(props.onLeave).toHaveBeenCalledTimes(1);
});
});

describe('accessibility', () => {
Expand Down
5 changes: 0 additions & 5 deletions client/src/components/ControlBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,6 @@ export default function ControlBar({
<ListItemIcon>{soundEnabled ? <VolumeUpIcon fontSize="small" /> : <VolumeOffIcon fontSize="small" />}</ListItemIcon>
<ListItemText primary="Sound effects" secondary={soundEnabled ? 'On' : 'Off'} />
</MenuItem>
<Divider />
<MenuItem onClick={() => { closeMore(); onLeave(); }} sx={{ color: 'error.main' }}>
<ListItemIcon><CallEndIcon fontSize="small" sx={{ color: 'error.main' }} /></ListItemIcon>
<ListItemText>Leave call</ListItemText>
</MenuItem>
</Menu>
</Box>
);
Expand Down
82 changes: 75 additions & 7 deletions client/src/hooks/useMediasoup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const H = vi.hoisted(() => {
return {
id: `producer-${appData?.mediaTag ?? appData?.source ?? 'x'}-${++state.pid}`,
appData, paused: false,
on: vi.fn(), pause: vi.fn(), resume: vi.fn(), close: vi.fn(),
on: vi.fn(), pause: vi.fn(), resume: vi.fn(), close: vi.fn(), replaceTrack: vi.fn(async () => {}),
};
}
function makeConsumer(id: string, producerId: string, kind: string) {
Expand Down Expand Up @@ -281,13 +281,69 @@ describe('useMediasoup', () => {
expect(H.request).toHaveBeenCalledWith('sfu-resume-producer', expect.objectContaining({ producerId: expect.any(String) }));
});

it('pauses the local video producer when toggled off', async () => {
it('pauses video and releases the captured camera track when toggled off', async () => {
const { result } = mount();
await waitForSetup();
expect(result.current.localVideoOn).toBe(true);
const cameraTrack = result.current.localStream?.getVideoTracks()[0];
expect(cameraTrack).toBeDefined();

await act(async () => { await result.current.toggleVideo(); });
expect(result.current.localVideoOn).toBe(false);
expect(cameraTrack?.stop).toHaveBeenCalledOnce();
expect(result.current.localStream?.getVideoTracks()).toHaveLength(0);
expect(H.request).toHaveBeenCalledWith(
'sfu-pause-producer',
expect.objectContaining({ producerId: expect.any(String) }),
);
});

it('reacquires camera video and resumes the existing producer after camera-off', async () => {
const { result } = mount({ startAudioOn: true, startVideoOn: true, videoDeviceId: 'cam-1' });
await waitForSetup();
const firstTrack = result.current.localStream?.getVideoTracks()[0];

await act(async () => { await result.current.toggleVideo(); });
await act(async () => { await result.current.toggleVideo(); });

expect(result.current.localVideoOn).toBe(true);
const replacementTrack = result.current.localStream?.getVideoTracks()[0];
expect(replacementTrack).toBeDefined();
expect(replacementTrack).not.toBe(firstTrack);
expect(replacementTrack?.getSettings().deviceId).toBe('cam-1');
const videoRequests = vi.mocked(navigator.mediaDevices.getUserMedia).mock.calls
.filter(([constraints]) => Boolean(constraints?.video));
expect(videoRequests).toHaveLength(2);
expect(H.request).toHaveBeenCalledWith(
'sfu-resume-producer',
expect.objectContaining({ producerId: expect.any(String) }),
);
});

it('acquires and produces camera video when enabled after joining camera-off', async () => {
const { result } = mount({ startAudioOn: true, startVideoOn: false, videoDeviceId: 'cam-1' });
await waitForSetup();
expect(H.produced.map((p: any) => p.appData?.mediaTag)).not.toContain('video');

await act(async () => { await result.current.toggleVideo(); });

expect(result.current.localVideoOn).toBe(true);
expect(result.current.localStream?.getVideoTracks()).toHaveLength(1);
expect(H.produced.map((p: any) => p.appData?.mediaTag)).toContain('video');
});

it('keeps camera off without leaking a track when reacquisition fails', async () => {
const { result } = mount({ startAudioOn: true, startVideoOn: true });
await waitForSetup();

await act(async () => { await result.current.toggleVideo(); });
vi.mocked(navigator.mediaDevices.getUserMedia).mockRejectedValueOnce(
Object.assign(new Error('blocked'), { name: 'NotAllowedError' }),
);
await act(async () => { await result.current.toggleVideo(); });

expect(result.current.localVideoOn).toBe(false);
expect(result.current.localStream?.getVideoTracks()).toHaveLength(0);
});

it('toggles raise-hand on then off, emitting both states', async () => {
Expand Down Expand Up @@ -370,19 +426,31 @@ describe('useMediasoup', () => {
expect(result.current.hasCamera).toBe(false);
});

it('does not acquire or produce camera video when joining camera-off', async () => {
const { result } = mount({ startAudioOn: true, startVideoOn: false });
await waitForSetup();

expect(result.current.localVideoOn).toBe(false);
const videoRequests = vi.mocked(navigator.mediaDevices.getUserMedia).mock.calls
.filter(([constraints]) => Boolean(constraints?.video));
expect(videoRequests).toHaveLength(0);
const producedTags = H.produced.map((p: { appData?: { mediaTag?: string } }) => p.appData?.mediaTag);
expect(producedTags).not.toContain('video');
});

it('defaults to muted camera-off when start flags are omitted', async () => {
const { result } = mount({});
await waitForSetup();

expect(result.current.localAudioOn).toBe(false);
expect(result.current.localVideoOn).toBe(false);
const producedTags = H.produced.map((p: { appData?: { mediaTag?: string } }) => p.appData?.mediaTag);
expect(producedTags).toEqual(expect.arrayContaining(['audio', 'video']));
expect(producedTags).toEqual(['audio']);
const pauseCalls = H.request.mock.calls.filter(([event]: [string]) => event === 'sfu-pause-producer');
expect(pauseCalls).toHaveLength(2);
expect(pauseCalls).toHaveLength(1);
});

it('produces in the paused state when joining muted with camera off', async () => {
it('produces muted audio without acquiring camera video when both start off', async () => {
const { result } = mount({ startAudioOn: false, startVideoOn: false });
await waitForSetup();

Expand All @@ -391,9 +459,9 @@ describe('useMediasoup', () => {
expect(result.current.localVideoOn).toBe(false);
const producedTags = H.produced.map((p: any) => p.appData?.mediaTag);
expect(producedTags).toContain('audio');
expect(producedTags).toContain('video');
expect(producedTags).not.toContain('video');
const pauseCalls = H.request.mock.calls.filter(([event]: [string]) => event === 'sfu-pause-producer');
expect(pauseCalls).toHaveLength(2);
expect(pauseCalls).toHaveLength(1);
});

it('ignores a screen-share that the user cancels', async () => {
Expand Down
116 changes: 80 additions & 36 deletions client/src/hooks/useMediasoup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,49 +188,91 @@ export function useMediasoup(roomId: string, devices: MediaDevicesOptions = {})
const toggleVideo = useCallback(async () => {
const next = !desiredVideoOnRef.current;
desiredVideoOnRef.current = next;
localStreamRef.current?.getVideoTracks().forEach((track) => { track.enabled = next; });
setLocalVideoOn(next && Boolean(localStreamRef.current?.getVideoTracks().length));
const producer = producersRef.current.get('video');
if (producer) {
try {
if (next) {
producer.resume();
await request('sfu-resume-producer', { producerId: producer.id });
} else {
producer.pause();

if (!next) {
setLocalVideoOn(false);
const producer = producersRef.current.get('video');
producer?.pause();

const stream = localStreamRef.current;
stream?.getVideoTracks().forEach((track) => {
stream.removeTrack(track);
track.stop();
});
if (stream) setLocalStream(new MediaStream(stream.getTracks()));
setHasCamera(false);

if (producer) {
try {
await request('sfu-pause-producer', { producerId: producer.id });
} catch (err: unknown) {
if (import.meta.env.DEV) console.warn('[sfu] toggle video failed:', errorMessage(err));
}
} catch (err: unknown) {
if (import.meta.env.DEV) console.warn('[sfu] toggle video failed:', errorMessage(err));
}
return;
}

// The common first-join path already has a camera track. If there is no
// track (permission was granted later / no camera at entry), wait until the
// send transport exists rather than pretending the camera is on.
if (!next) return;
const sendTransport = sendTransportRef.current;
if (!sendTransport || !deviceRef.current?.canProduce('video')) return;
const localStream = localStreamRef.current;
if (!localStream) {
desiredVideoOnRef.current = false;
return;
}

const { videoDeviceId } = devicesRef.current;
const constraint = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true;
let track: MediaStreamTrack | undefined;
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: constraint });
const track = stream.getVideoTracks()[0];
if (!track || !localStreamRef.current) return;
localStreamRef.current.addTrack(track);
setLocalStream(new MediaStream(localStreamRef.current.getTracks()));
const newProducer = await sendTransport.produce({
track,
encodings: CAM_VIDEO_ENCODINGS,
codecOptions: CAM_VIDEO_CODEC_OPTIONS,
appData: { source: 'camera', mediaTag: 'video' },
track = stream.getVideoTracks()[0];
if (!track) {
stream.getTracks().forEach((candidate) => candidate.stop());
desiredVideoOnRef.current = false;
return;
}
if (!desiredVideoOnRef.current) {
track.stop();
return;
}

let producer = producersRef.current.get('video');
if (producer) {
await producer.replaceTrack({ track });
producer.resume();
} else {
const sendTransport = sendTransportRef.current;
if (sendTransport && deviceRef.current?.canProduce('video')) {
producer = await sendTransport.produce({
track,
encodings: CAM_VIDEO_ENCODINGS,
codecOptions: CAM_VIDEO_CODEC_OPTIONS,
appData: { source: 'camera', mediaTag: 'video' },
});
producersRef.current.set('video', producer);
producer.on('transportclose', () => producersRef.current.delete('video'));
}
}

localStream.getVideoTracks().forEach((oldTrack) => {
localStream.removeTrack(oldTrack);
oldTrack.stop();
});
producersRef.current.set('video', newProducer);
newProducer.on('transportclose', () => producersRef.current.delete('video'));
setLocalVideoOn(desiredVideoOnRef.current);
localStream.addTrack(track);
setLocalStream(new MediaStream(localStream.getTracks()));
setLocalVideoOn(true);
setHasCamera(true);

if (producer) {
try {
await request('sfu-resume-producer', { producerId: producer.id });
} catch (err: unknown) {
if (import.meta.env.DEV) console.warn('[sfu] toggle video failed:', errorMessage(err));
}
}
} catch (err: unknown) {
track?.stop();
desiredVideoOnRef.current = false;
setLocalVideoOn(false);
setHasCamera(false);
if (import.meta.env.DEV) console.warn('[sfu] camera still unavailable:', errorName(err));
}
}, []);
Expand Down Expand Up @@ -823,12 +865,14 @@ export function useMediasoup(roomId: string, devices: MediaDevicesOptions = {})
} catch (err: unknown) {
if (errorName(err) === 'NotAllowedError' || errorName(err) === 'NotFoundError') deniedCount++;
}
try {
const c = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true;
const s = await navigator.mediaDevices.getUserMedia({ video: c });
s.getVideoTracks().forEach((t) => stream.addTrack(t));
} catch (err: unknown) {
if (errorName(err) === 'NotAllowedError' || errorName(err) === 'NotFoundError') deniedCount++;
if (desiredVideoOnRef.current) {
try {
const c = videoDeviceId ? { deviceId: { exact: videoDeviceId } } : true;
const s = await navigator.mediaDevices.getUserMedia({ video: c });
s.getVideoTracks().forEach((t) => stream.addTrack(t));
} catch (err: unknown) {
if (errorName(err) === 'NotAllowedError' || errorName(err) === 'NotFoundError') deniedCount++;
}
}

if (deniedCount === 2) setPermissionDenied(true);
Expand Down
2 changes: 1 addition & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ DEEPGRAM_API_KEY=
GROQ_API_KEY=
DEEPGRAM_TRANSCRIPTION_MODEL=nova-3
GROQ_TRANSCRIPTION_MODEL=whisper-large-v3
GROQ_TRANSCRIPT_MERGE_MODEL=llama-3.3-70b-versatile
GROQ_TRANSCRIPT_MERGE_MODEL=qwen/qwen3.6-27b

# ── Rate limiting (issue #161) ─────────────────────────────────────────────
# All optional — the baked-in defaults below are generous enough that a normal
Expand Down
2 changes: 1 addition & 1 deletion server/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const env = {
groqApiKey: process.env.GROQ_API_KEY || '',
deepgramModel: process.env.DEEPGRAM_TRANSCRIPTION_MODEL || 'nova-3',
groqModel: process.env.GROQ_TRANSCRIPTION_MODEL || 'whisper-large-v3',
mergeModel: process.env.GROQ_TRANSCRIPT_MERGE_MODEL || 'llama-3.3-70b-versatile',
mergeModel: process.env.GROQ_TRANSCRIPT_MERGE_MODEL || 'qwen/qwen3.6-27b',
},
};

Expand Down
1 change: 1 addition & 0 deletions server/src/transcription/groq-refiner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export class GroqTranscriptRefiner {
try {
const completion = await this.client.chat.completions.create({
model: env.transcription.mergeModel,
reasoning_effort: 'none',
temperature: 0,
max_completion_tokens: 320,
messages: [
Expand Down
29 changes: 28 additions & 1 deletion server/test/groq-refiner.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { buildPcm16Wav } from '../src/transcription/groq-refiner.js';
import { buildPcm16Wav, GroqTranscriptRefiner } from '../src/transcription/groq-refiner.js';

describe('Groq transcription WAV preparation', () => {
it('wraps 16 kHz mono PCM in a valid WAV container', () => {
Expand All @@ -13,4 +13,31 @@ describe('Groq transcription WAV preparation', () => {
expect(wav.readUInt32LE(40)).toBe(pcm.length);
expect(wav.subarray(44)).toEqual(pcm);
});

it('disables Qwen reasoning for transcript merge requests', async () => {
let mergeRequest;
const refiner = new GroqTranscriptRefiner();
refiner.client = {
audio: {
transcriptions: {
create: async () => ({ text: 'deploy the websocket service' }),
},
},
chat: {
completions: {
create: async (request) => {
mergeRequest = request;
return { choices: [{ message: { content: 'Deploy the WebSocket service.' } }] };
},
},
},
};

await refiner.refine({
pcm: Buffer.alloc(16000),
deepgramText: 'Deploy the web socket server.',
});

expect(mergeRequest.reasoning_effort).toBe('none');
});
});
Loading
Loading