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 ? : } - - { closeMore(); onLeave(); }} sx={{ color: 'error.main' }}> - - Leave call - ); 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'); + }); +});