diff --git a/client/src/components/ControlBar.test.tsx b/client/src/components/ControlBar.test.tsx
index 1182102..351d256 100644
--- a/client/src/components/ControlBar.test.tsx
+++ b/client/src/components/ControlBar.test.tsx
@@ -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);
});
@@ -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', () => {
diff --git a/client/src/components/ControlBar.tsx b/client/src/components/ControlBar.tsx
index e5881bb..ae3bf64 100644
--- a/client/src/components/ControlBar.tsx
+++ b/client/src/components/ControlBar.tsx
@@ -470,11 +470,6 @@ export default function ControlBar({
{soundEnabled ? : }
-
-
);
diff --git a/client/src/hooks/useMediasoup.test.tsx b/client/src/hooks/useMediasoup.test.tsx
index 9f0fb3d..6aaf699 100644
--- a/client/src/hooks/useMediasoup.test.tsx
+++ b/client/src/hooks/useMediasoup.test.tsx
@@ -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) {
@@ -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 () => {
@@ -370,6 +426,18 @@ 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();
@@ -377,12 +445,12 @@ describe('useMediasoup', () => {
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();
@@ -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 () => {
diff --git a/client/src/hooks/useMediasoup.ts b/client/src/hooks/useMediasoup.ts
index 82be690..70d0722 100644
--- a/client/src/hooks/useMediasoup.ts
+++ b/client/src/hooks/useMediasoup.ts
@@ -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));
}
}, []);
@@ -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);
diff --git a/server/.env.example b/server/.env.example
index 361c53e..df48a19 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -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
diff --git a/server/src/config/env.ts b/server/src/config/env.ts
index 3089293..505412c 100644
--- a/server/src/config/env.ts
+++ b/server/src/config/env.ts
@@ -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',
},
};
diff --git a/server/src/transcription/groq-refiner.ts b/server/src/transcription/groq-refiner.ts
index 1e68038..1e5b10f 100644
--- a/server/src/transcription/groq-refiner.ts
+++ b/server/src/transcription/groq-refiner.ts
@@ -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: [
diff --git a/server/test/groq-refiner.test.js b/server/test/groq-refiner.test.js
index a2e1492..2cb2c9c 100644
--- a/server/test/groq-refiner.test.js
+++ b/server/test/groq-refiner.test.js
@@ -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', () => {
@@ -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');
+ });
});
diff --git a/server/test/transcription-env.test.js b/server/test/transcription-env.test.js
new file mode 100644
index 0000000..9e2eaca
--- /dev/null
+++ b/server/test/transcription-env.test.js
@@ -0,0 +1,19 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+const originalMergeModel = process.env.GROQ_TRANSCRIPT_MERGE_MODEL;
+
+afterEach(() => {
+ if (originalMergeModel === undefined) delete process.env.GROQ_TRANSCRIPT_MERGE_MODEL;
+ else process.env.GROQ_TRANSCRIPT_MERGE_MODEL = originalMergeModel;
+ vi.resetModules();
+});
+
+describe('transcription model defaults', () => {
+ it('uses Groq\'s supported Qwen replacement for transcript merging', async () => {
+ process.env.GROQ_TRANSCRIPT_MERGE_MODEL = '';
+ vi.resetModules();
+ const { env } = await import('../src/config/env.js');
+
+ expect(env.transcription.mergeModel).toBe('qwen/qwen3.6-27b');
+ });
+});