diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ab50ae..b2ae88a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ _Feature module implementation (iam, billing, workspace, discovery) in progress. ### Added +- **Discovery — virtual-meeting audio capture** (`feature/system-audio-capture`): the analyst can now + record a Zoom / Google Meet / Teams call, not only a face-to-face meeting. A compact **audio-source + picker** next to the record button chooses **In person (microphone)** or **Virtual meeting (microphone + + meeting audio)**, remembered per user in localStorage. The virtual source opens the browser's + screen-share picker straight from the record click (`getDisplayMedia` with tab/window/system audio, voice + processing off and ReqsAI's own tab hidden), drops the unused video track and mixes the shared audio with + the mic in one mono Web Audio mixer — the backend keeps receiving the same 16 kHz Int16 PCM on `/ws/stt`, + and the level meter shows the mix. Sharing without ticking "Share tab audio" warns and lets the user + retry; a dismissed picker stays idle silently; "Stop sharing" mid-session keeps recording the mic with a + notice and a **Share again** action in the session bar; pausing keeps the shared tab, so resuming never + re-opens the picker. The virtual option is disabled (with the reason) outside desktop Chrome/Edge, and a + hint recommends headphones to avoid a duplicated transcript (`discovery.source.*` plus new + `discovery.rec.*` / `discovery.bar.*` keys, en/es). - **Discovery — live session presence** (`feature/discovery-presence`): the discovery chat now shows who else is viewing the **live** session — an overlapping avatar stack with a live pulse, a "+N" overflow bubble and a viewer count, in both the page header and the live session bar. It is fed by a new diff --git a/public/i18n/en.json b/public/i18n/en.json index 3639804..491ae3e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1082,12 +1082,22 @@ "empty": "No language found.", "locked": "The language is fixed once recording starts." }, + "source": { + "label": "Audio source", + "mic": "In person (microphone)", + "meeting": "Virtual meeting (microphone + meeting audio)", + "unsupported": "Virtual meeting: available in desktop Chrome or Edge.", + "hint": "When sharing, pick the meeting tab and tick \"Share tab audio\". Use headphones to avoid a duplicated transcript." + }, "bar": { "recording": "Recording", "paused": "Paused", "pause": "Pause", "resume": "Resume", - "stop": "Stop" + "stop": "Stop", + "meetingAudio": "Recording microphone + meeting audio", + "meetingAudioLost": "Meeting audio is not being captured. Share the meeting tab again.", + "reshare": "Share again" }, "rec": { "micDenied": "Microphone permission denied. Enable it in your browser settings.", @@ -1095,7 +1105,11 @@ "wsError": "Could not connect to the transcription server.", "wsClosed": "The transcription connection was closed.", "initFailed": "Could not start recording.", - "audioInitFailed": "Could not initialize the local audio processor." + "audioInitFailed": "Could not initialize the local audio processor.", + "noMeetingAudio": "No audio was shared. Pick the meeting tab and tick \"Share tab audio\".", + "meetingAudioEnded": "Meeting audio sharing stopped. Recording continues with the microphone only.", + "meetingUnsupported": "Your browser cannot capture meeting audio. Use desktop Chrome or Edge.", + "shareFailed": "Could not capture the selected tab, window or screen." }, "queue": { "pendingBadge": "{{count}} suggestions pending", diff --git a/public/i18n/es.json b/public/i18n/es.json index 1b55c02..b856dcb 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1082,12 +1082,22 @@ "empty": "No se encontró el idioma.", "locked": "El idioma queda fijo al iniciar la grabación." }, + "source": { + "label": "Fuente de audio", + "mic": "Presencial (micrófono)", + "meeting": "Reunión virtual (micrófono + audio de la reunión)", + "unsupported": "Reunión virtual: disponible en Chrome o Edge de escritorio.", + "hint": "Al compartir, elige la pestaña de la reunión y marca «Compartir audio de la pestaña». Usa audífonos para no duplicar la transcripción." + }, "bar": { "recording": "Grabando", "paused": "En pausa", "pause": "Pausar", "resume": "Reanudar", - "stop": "Detener" + "stop": "Detener", + "meetingAudio": "Grabando micrófono + audio de la reunión", + "meetingAudioLost": "No se está capturando el audio de la reunión. Vuelve a compartir la pestaña de la reunión.", + "reshare": "Compartir de nuevo" }, "rec": { "micDenied": "Permiso de micrófono denegado. Habilítalo en los ajustes de tu navegador.", @@ -1095,7 +1105,11 @@ "wsError": "No se pudo conectar con el servidor de transcripción.", "wsClosed": "Se cerró la conexión de transcripción.", "initFailed": "No se pudo iniciar la grabación.", - "audioInitFailed": "No se pudo inicializar el procesador de audio local." + "audioInitFailed": "No se pudo inicializar el procesador de audio local.", + "noMeetingAudio": "No se compartió audio. Elige la pestaña de la reunión y marca «Compartir audio de la pestaña».", + "meetingAudioEnded": "Se dejó de compartir el audio de la reunión. La grabación continúa solo con el micrófono.", + "meetingUnsupported": "Tu navegador no puede capturar el audio de la reunión. Usa Chrome o Edge de escritorio.", + "shareFailed": "No se pudo capturar la pestaña, ventana o pantalla elegida." }, "queue": { "pendingBadge": "{{count}} sugerencias pendientes", diff --git a/src/app/core/audio/audio-recorder.service.spec.ts b/src/app/core/audio/audio-recorder.service.spec.ts new file mode 100644 index 0000000..3f175ec --- /dev/null +++ b/src/app/core/audio/audio-recorder.service.spec.ts @@ -0,0 +1,385 @@ +import { TestBed } from '@angular/core/testing'; +import { MockInstance, vi } from 'vitest'; +import { AuthStore } from '../auth/auth.store'; +import { AuthResponse } from '../auth/auth.model'; +import { AudioRecorderService } from './audio-recorder.service'; + +/** A capture track double: `stop()` is observable and `ended` can be dispatched. */ +class FakeTrack extends EventTarget { + readyState: MediaStreamTrackState = 'live'; + readonly stop = vi.fn(() => { + this.readyState = 'ended'; + }); + + constructor(readonly kind: 'audio' | 'video') { + super(); + } +} + +function fakeStream(...tracks: FakeTrack[]): MediaStream { + return { + getTracks: () => tracks, + getAudioTracks: () => tracks.filter((t) => t.kind === 'audio'), + getVideoTracks: () => tracks.filter((t) => t.kind === 'video'), + } as unknown as MediaStream; +} + +/** The share picker result: a live audio + video pair, as Chrome returns for "Share tab audio". */ +function sharedTab(): { stream: MediaStream; audio: FakeTrack; video: FakeTrack } { + const audio = new FakeTrack('audio'); + const video = new FakeTrack('video'); + return { stream: fakeStream(video, audio), audio, video }; +} + +function micStream(): { stream: MediaStream; track: FakeTrack } { + const track = new FakeTrack('audio'); + return { stream: fakeStream(track), track }; +} + +describe('AudioRecorderService — audio sources', () => { + let recorder: AudioRecorderService; + let getDisplayMedia: MockInstance; + let getUserMedia: MockInstance; + + beforeEach(() => { + TestBed.configureTestingModule({}); + recorder = TestBed.inject(AudioRecorderService); + // Never reach the real devices: each test scripts what the browser answers. + getDisplayMedia = vi + .spyOn(navigator.mediaDevices, 'getDisplayMedia') + .mockRejectedValue(new Error('unscripted getDisplayMedia')); + getUserMedia = vi + .spyOn(navigator.mediaDevices, 'getUserMedia') + .mockRejectedValue(new Error('unscripted getUserMedia')); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + recorder.stopStreaming(); + vi.restoreAllMocks(); + }); + + describe('in person (microphone)', () => { + it('captures the microphone only', async () => { + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission('mic')).toBe(true); + + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('mic'); + expect(recorder.meetingAudio()).toBe(false); + }); + + it('defaults to the microphone', async () => { + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission()).toBe(true); + + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('mic'); + }); + }); + + describe('virtual meeting (microphone + meeting audio)', () => { + it('opens the share picker synchronously, before the microphone, to keep the click activation', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(micStream().stream); + + const pending = recorder.requestPermission('meeting'); + // No await has happened yet: the picker must already be requested. + expect(getDisplayMedia).toHaveBeenCalledTimes(1); + expect(getUserMedia).not.toHaveBeenCalled(); + + expect(await pending).toBe(true); + expect(getUserMedia).toHaveBeenCalledWith({ audio: true }); + expect(getDisplayMedia.mock.invocationCallOrder[0]).toBeLessThan( + getUserMedia.mock.invocationCallOrder[0], + ); + }); + + it('asks for unprocessed system/tab audio, hides its own tab and allows switching', async () => { + getDisplayMedia.mockResolvedValue(sharedTab().stream); + getUserMedia.mockResolvedValue(micStream().stream); + + await recorder.requestPermission('meeting'); + + expect(getDisplayMedia).toHaveBeenCalledWith( + expect.objectContaining({ + video: true, + audio: expect.objectContaining({ + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + }), + systemAudio: 'include', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', + }), + ); + }); + + it('keeps the meeting audio and drops the unused video track', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(micStream().stream); + + expect(await recorder.requestPermission('meeting')).toBe(true); + + expect(tab.video.stop).toHaveBeenCalled(); + expect(tab.audio.stop).not.toHaveBeenCalled(); + expect(recorder.source()).toBe('meeting'); + expect(recorder.meetingAudio()).toBe(true); + expect(recorder.error()).toBeNull(); + expect(recorder.notice()).toBeNull(); + }); + + it('warns and does not start when the user shared without ticking "Share tab audio"', async () => { + const video = new FakeTrack('video'); + getDisplayMedia.mockResolvedValue(fakeStream(video)); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(video.stop).toHaveBeenCalled(); + expect(getUserMedia).not.toHaveBeenCalled(); + expect(recorder.notice()).toBe('discovery.rec.noMeetingAudio'); + expect(recorder.error()).toBeNull(); + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.source()).toBe('mic'); + }); + + it.each(['NotAllowedError', 'AbortError'])( + 'stays idle without any message when the picker is dismissed (%s)', + async (name) => { + getDisplayMedia.mockRejectedValue(new DOMException('dismissed', name)); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(getUserMedia).not.toHaveBeenCalled(); + expect(recorder.error()).toBeNull(); + expect(recorder.notice()).toBeNull(); + expect(console.error).not.toHaveBeenCalled(); + }, + ); + + it('reports a real capture failure', async () => { + getDisplayMedia.mockRejectedValue(new DOMException('busy', 'NotReadableError')); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(recorder.error()).toBe('discovery.rec.shareFailed'); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it('reports an unsupported browser when getDisplayMedia is missing', async () => { + Object.defineProperty(navigator.mediaDevices, 'getDisplayMedia', { + value: undefined, + configurable: true, + }); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(recorder.error()).toBe('discovery.rec.meetingUnsupported'); + expect(getUserMedia).not.toHaveBeenCalled(); + }); + + it('releases the shared tab when the microphone is denied', async () => { + const tab = sharedTab(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockRejectedValue(new DOMException('denied', 'NotAllowedError')); + + expect(await recorder.requestPermission('meeting')).toBe(false); + + expect(tab.audio.stop).toHaveBeenCalled(); + expect(recorder.error()).toBe('discovery.rec.micDenied'); + expect(recorder.meetingAudio()).toBe(false); + }); + }); + + describe('"Stop sharing" mid-session', () => { + it('falls back to the microphone with a notice instead of ending the recording', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + + tab.audio.dispatchEvent(new Event('ended')); + + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + expect(mic.track.stop).not.toHaveBeenCalled(); + // Still a meeting recording: the bar offers to share again. + expect(recorder.source()).toBe('meeting'); + }); + + it('re-shares the meeting audio from the session bar', async () => { + const first = sharedTab(); + const second = sharedTab(); + getDisplayMedia.mockResolvedValueOnce(first.stream).mockResolvedValueOnce(second.stream); + getUserMedia.mockResolvedValue(micStream().stream); + await recorder.requestPermission('meeting'); + first.audio.dispatchEvent(new Event('ended')); + + expect(await recorder.shareMeetingAudio()).toBe(true); + + expect(recorder.meetingAudio()).toBe(true); + expect(recorder.notice()).toBeNull(); + // A late event from the old share never drops the new one. + first.audio.dispatchEvent(new Event('ended')); + expect(recorder.meetingAudio()).toBe(true); + expect(second.audio.stop).not.toHaveBeenCalled(); + }); + + it('keeps the "meeting audio lost" notice when the re-share picker is dismissed', async () => { + const tab = sharedTab(); + getDisplayMedia + .mockResolvedValueOnce(tab.stream) + .mockRejectedValueOnce(new DOMException('dismissed', 'NotAllowedError')); + getUserMedia.mockResolvedValue(micStream().stream); + await recorder.requestPermission('meeting'); + tab.audio.dispatchEvent(new Event('ended')); + + expect(await recorder.shareMeetingAudio()).toBe(false); + + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + expect(recorder.meetingAudio()).toBe(false); + }); + }); + + describe('pause and stop', () => { + it('keeps the shared tab across a pause (no picker on resume) but releases the microphone', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + + recorder.pauseStreaming(); + + expect(mic.track.stop).toHaveBeenCalled(); + expect(tab.audio.stop).not.toHaveBeenCalled(); + expect(recorder.meetingAudio()).toBe(true); + }); + + it('releases everything on stop and returns to the microphone source', async () => { + const tab = sharedTab(); + const mic = micStream(); + getDisplayMedia.mockResolvedValue(tab.stream); + getUserMedia.mockResolvedValue(mic.stream); + await recorder.requestPermission('meeting'); + tab.audio.dispatchEvent(new Event('ended')); + + recorder.stopStreaming(); + + expect(mic.track.stop).toHaveBeenCalled(); + expect(tab.audio.stop).toHaveBeenCalled(); + expect(recorder.meetingAudio()).toBe(false); + expect(recorder.source()).toBe('mic'); + expect(recorder.notice()).toBeNull(); + }); + }); +}); + +/** Minimal `/ws/stt` socket that opens on the next macrotask and records what is sent. */ +class FakeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static last: FakeWebSocket | null = null; + + readyState = FakeWebSocket.CONNECTING; + binaryType = 'blob'; + onopen: (() => void) | null = null; + onerror: ((e: Event) => void) | null = null; + onclose: ((e: CloseEvent) => void) | null = null; + readonly close = vi.fn(() => { + this.readyState = FakeWebSocket.CLOSED; + }); + + constructor(readonly url: string) { + FakeWebSocket.last = this; + setTimeout(() => { + this.readyState = FakeWebSocket.OPEN; + this.onopen?.(); + }); + } + + send(): void { + // PCM chunks are irrelevant to the graph-wiring assertions. + } +} + +describe('AudioRecorderService — mixing', () => { + let recorder: AudioRecorderService; + let toneContext: AudioContext; + + /** A real, silent audio track (the Web Audio graph rejects doubles). */ + function realAudioStream(): MediaStream { + return toneContext.createMediaStreamDestination().stream; + } + + beforeEach(() => { + TestBed.configureTestingModule({}); + TestBed.inject(AuthStore).setSession({ + accessToken: 'token', + user: { id: 'user-1' }, + organizationId: null, + } as unknown as AuthResponse); + recorder = TestBed.inject(AudioRecorderService); + toneContext = new AudioContext(); + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(async () => { + recorder.stopStreaming(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await toneContext.close(); + }); + + it('sums the mic and the meeting audio into one mono mixer and keeps the mic when sharing stops', async () => { + const display = realAudioStream(); + const mic = realAudioStream(); + const displayTrack = display.getAudioTracks()[0]; + vi.spyOn(navigator.mediaDevices, 'getDisplayMedia').mockResolvedValue(display); + vi.spyOn(navigator.mediaDevices, 'getUserMedia').mockResolvedValue(mic); + const createGain = vi.spyOn(AudioContext.prototype, 'createGain'); + const createSource = vi.spyOn(AudioContext.prototype, 'createMediaStreamSource'); + const connect = vi.spyOn(AudioNode.prototype, 'connect'); + /** The nodes `from` feeds, in connection order. */ + const targetsOf = (from: AudioNode): unknown[] => + connect.mock.calls.filter((_, i) => connect.mock.contexts[i] === from).map(([to]) => to); + + expect(await recorder.requestPermission('meeting')).toBe(true); + await recorder.startStreaming('sess-1'); + // The worklet is wired last, once its module has loaded. + await vi.waitFor(() => + expect(connect.mock.calls.some(([to]) => to instanceof AudioWorkletNode)).toBe(true), + ); + + const mixer = createGain.mock.results[0].value as GainNode; + expect(mixer.channelCount).toBe(1); + expect(mixer.channelCountMode).toBe('explicit'); + expect(mixer.channelInterpretation).toBe('speakers'); + expect(createSource.mock.calls.map(([stream]) => stream)).toEqual([mic, display]); + for (const { value: source } of createSource.mock.results) { + expect(targetsOf(source as AudioNode)).toEqual([mixer]); + } + // Both the level meter and the PCM worklet read the mix, not a single source. + const [meter, worklet] = targetsOf(mixer); + expect(meter).toBeInstanceOf(AnalyserNode); + expect(worklet).toBeInstanceOf(AudioWorkletNode); + + const displaySource = createSource.mock.results[1].value as MediaStreamAudioSourceNode; + const disconnect = vi.spyOn(displaySource, 'disconnect'); + displayTrack.dispatchEvent(new Event('ended')); + + expect(disconnect).toHaveBeenCalled(); + expect(recorder.streaming()).toBe(true); + expect(FakeWebSocket.last?.close).not.toHaveBeenCalled(); + expect(recorder.notice()).toBe('discovery.rec.meetingAudioEnded'); + }); +}); diff --git a/src/app/core/audio/audio-recorder.service.ts b/src/app/core/audio/audio-recorder.service.ts index fbc1cb0..794e5d6 100644 --- a/src/app/core/audio/audio-recorder.service.ts +++ b/src/app/core/audio/audio-recorder.service.ts @@ -1,26 +1,42 @@ import { Injectable, inject, signal } from '@angular/core'; import { AuthStore } from '../auth/auth.store'; import { environment } from '../../../environments/environment'; +import { AudioSource, MEETING_DISPLAY_MEDIA_OPTIONS, isCaptureDismissed } from './audio-source'; /** Number of bars exposed by the live input-level meter. */ const LEVEL_BARS = 12; /** How often the level meter refreshes (ms) — cheap enough to leave running. */ const LEVEL_INTERVAL_MS = 100; +/** Outcome of asking the user to share the meeting's tab, window or screen. */ +type MeetingShare = 'shared' | 'dismissed' | 'no-audio' | 'unsupported' | 'failed'; + /** - * Handles browser microphone recording, Web Audio downsampling to 16kHz 16-bit Mono PCM, + * Handles browser audio capture, Web Audio downsampling to 16kHz 16-bit Mono PCM, * and real-time binary WebSocket streaming to the backend STT endpoint `/ws/stt`. * + * Two sources (see {@link AudioSource}): the microphone alone, or — for virtual + * meetings — the microphone mixed with the audio of a surface shared through + * the screen-share picker. Both feed one mono mixer, so the backend always gets + * the same PCM stream. The shared surface survives pauses (resuming never + * re-opens the picker); if the user stops sharing, the mic keeps streaming. + * * `error` holds an i18n key (`discovery.rec.*`) — the UI translates it, so this - * service stays locale-agnostic. `levels` is a small AnalyserNode-driven input - * meter (0..1 per bar) for a real waveform while streaming. + * service stays locale-agnostic; `notice` is the non-fatal counterpart (e.g. + * meeting audio lost while the recording carries on). `levels` is a small + * AnalyserNode-driven input meter (0..1 per bar) of the mix while streaming. */ @Injectable({ providedIn: 'root' }) export class AudioRecorderService { private readonly auth = inject(AuthStore); - private mediaStream: MediaStream | null = null; + private micStream: MediaStream | null = null; + /** The shared meeting surface (its video track already stopped); outlives pauses. */ + private displayStream: MediaStream | null = null; private audioContext: AudioContext | null = null; + /** Mono summing node every source feeds; drives the level meter and the worklet. */ + private mixer: GainNode | null = null; + private displaySource: MediaStreamAudioSourceNode | null = null; private audioWorkletNode: AudioWorkletNode | null = null; private analyser: AnalyserNode | null = null; private levelTimer: ReturnType | null = null; @@ -29,32 +45,67 @@ export class AudioRecorderService { /** i18n key of the current error, or null. */ readonly error = signal(null); + /** i18n key of a non-fatal warning (the recording, if any, carries on), or null. */ + readonly notice = signal(null); readonly streaming = signal(false); /** Live input levels (0..1 per bar); empty while not streaming. */ readonly levels = signal([]); + /** Source of the current recording; back to `mic` once it stops. */ + readonly source = signal('mic'); + /** True while shared meeting audio is held (and mixed in whenever streaming). */ + readonly meetingAudio = signal(false); /** - * Request microphone permission. Returns true if granted. + * Secures the capture for `source` before a recording starts. Returns true if granted. + * For `meeting` the screen-share picker opens FIRST: it needs the click's transient + * activation, which any earlier await could use up. The microphone follows. */ - async requestPermission(): Promise { - try { - this.error.set(null); - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - // Store the stream so we can reuse or stop it. - this.mediaStream = stream; - return true; - } catch (err) { - console.error('Microphone permission denied/failed:', err); - this.error.set('discovery.rec.micDenied'); + async requestPermission(source: AudioSource = 'mic'): Promise { + this.error.set(null); + this.notice.set(null); + this.releaseMic(); + + if (source === 'meeting') { + const share = await this.shareDisplay(); + if (share !== 'shared') { + // Nothing starts: the user stays idle and can retry with one click. + this.reportShare(share); + return false; + } + } else { + this.releaseDisplay(); + } + + if (!(await this.acquireMic())) { + this.releaseDisplay(); return false; } + this.source.set(source); + return true; } /** - * Connects to `/ws/stt` and begins downsampling and streaming microphone input. + * Re-opens the screen-share picker during a session (e.g. after "Stop sharing") + * and mixes the new meeting audio into the live recording, or into the next + * resume while paused. Call it straight from a click handler. + */ + async shareMeetingAudio(): Promise { + this.error.set(null); + const share = await this.shareDisplay(); + if (share !== 'shared') { + this.reportShare(share); + return false; + } + this.notice.set(null); + this.source.set('meeting'); + return true; + } + + /** + * Connects to `/ws/stt` and begins downsampling and streaming the captured audio. */ async startStreaming(sessionId: string): Promise { - this.stopStreaming(); + this.stopPipeline(); this.error.set(null); const token = this.auth.accessToken(); @@ -64,11 +115,15 @@ export class AudioRecorderService { } try { - // Ensure microphone access has been requested - if (!this.mediaStream) { - const granted = await this.requestPermission(); + // The mic is released on pause, so a resume re-acquires it (no prompt once granted). + if (!this.micStream) { + const granted = await this.acquireMic(); if (!granted) return; } + if (this.source() === 'meeting' && !this.displayStream) { + // Sharing ended earlier (e.g. while paused): carry on with the mic only. + this.notice.set('discovery.rec.meetingAudioEnded'); + } const url = sttUrl(sessionId, token); this.webSocket = new WebSocket(url); @@ -79,10 +134,12 @@ export class AudioRecorderService { void this.startAudioProcessing(); }; + // Transport failures keep the shared meeting surface, so pause/resume can + // reconnect without the picker; stopping the session releases it. this.webSocket.onerror = (e) => { console.error('STT WebSocket error:', e); this.error.set('discovery.rec.wsError'); - this.stopStreaming(); + this.pauseStreaming(); }; this.webSocket.onclose = (event) => { @@ -91,7 +148,7 @@ export class AudioRecorderService { console.warn(`STT WebSocket closed abnormally: ${event.code} (${event.reason})`); this.error.set('discovery.rec.wsClosed'); } - this.stopStreaming(); + this.pauseStreaming(); }; } catch (err) { console.error('Failed to start streaming:', err); @@ -101,9 +158,119 @@ export class AudioRecorderService { } /** - * Stops recording, releases the microphone, and closes the WebSocket connection. + * Pauses: closes the WebSocket and the audio graph and releases the microphone, + * but keeps the shared meeting surface so resuming never re-opens the picker. + */ + pauseStreaming(): void { + this.stopPipeline(); + this.releaseMic(); + } + + /** + * Stops recording, releases the microphone and the shared meeting surface, + * and closes the WebSocket connection. */ stopStreaming(): void { + this.pauseStreaming(); + this.releaseDisplay(); + this.source.set('mic'); + this.notice.set(null); + } + + private async acquireMic(): Promise { + try { + this.micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + return true; + } catch (err) { + console.error('Microphone permission denied/failed:', err); + this.error.set('discovery.rec.micDenied'); + return false; + } + } + + /** + * Opens the screen-share picker and keeps the chosen surface's audio. Its first + * await is `getDisplayMedia` itself, so the caller's click activation is intact. + */ + private async shareDisplay(): Promise { + if (typeof navigator.mediaDevices?.getDisplayMedia !== 'function') return 'unsupported'; + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getDisplayMedia(MEETING_DISPLAY_MEDIA_OPTIONS); + } catch (err) { + if (isCaptureDismissed(err)) return 'dismissed'; + console.error('Meeting audio capture failed:', err); + return 'failed'; + } + + const [audioTrack] = stream.getAudioTracks(); + if (!audioTrack) { + // Shared without ticking "Share tab audio" (or a surface that carries none). + stream.getTracks().forEach((track) => track.stop()); + return 'no-audio'; + } + + // Only the audio is used. Stopping the video track ends the frame capture while + // the audio track stays live, and "Stop sharing" still ends the audio track. + stream.getVideoTracks().forEach((track) => track.stop()); + this.releaseDisplay(); + this.displayStream = stream; + audioTrack.addEventListener('ended', () => this.onMeetingAudioEnded(stream), { once: true }); + this.meetingAudio.set(true); + this.connectMeetingAudio(); + return 'shared'; + } + + /** + * Surfaces a failed share. A dismissed picker is the user's own choice, so it + * reports nothing (and keeps any current notice, e.g. "meeting audio lost"). + */ + private reportShare(share: Exclude): void { + switch (share) { + case 'dismissed': + break; + case 'no-audio': + this.notice.set('discovery.rec.noMeetingAudio'); + break; + case 'unsupported': + this.error.set('discovery.rec.meetingUnsupported'); + break; + case 'failed': + this.error.set('discovery.rec.shareFailed'); + break; + } + } + + /** "Stop sharing" or the shared tab closing: drop the meeting audio, keep the mic. */ + private onMeetingAudioEnded(stream: MediaStream): void { + if (this.displayStream !== stream) return; + this.releaseDisplay(); + this.notice.set('discovery.rec.meetingAudioEnded'); + } + + /** Feeds the held meeting audio into the live mixer (no-op until the graph exists). */ + private connectMeetingAudio(): void { + if (!this.audioContext || !this.mixer || !this.displayStream || this.displaySource) return; + this.displaySource = this.audioContext.createMediaStreamSource(this.displayStream); + this.displaySource.connect(this.mixer); + } + + private releaseMic(): void { + this.micStream?.getTracks().forEach((track) => track.stop()); + this.micStream = null; + } + + private releaseDisplay(): void { + this.displaySource?.disconnect(); + this.displaySource = null; + this.displayStream?.getTracks().forEach((track) => track.stop()); + this.displayStream = null; + this.meetingAudio.set(false); + } + + /** Tears down the WebSocket and the audio graph, leaving the captured streams alone. */ + private stopPipeline(): void { this.streaming.set(false); if (this.levelTimer !== null) { @@ -119,6 +286,8 @@ export class AudioRecorderService { this.audioWorkletNode = null; } + this.displaySource = null; + this.mixer = null; if (this.audioContext) { if (this.audioContext.state !== 'closed') { void this.audioContext.close(); @@ -126,12 +295,11 @@ export class AudioRecorderService { this.audioContext = null; } - if (this.mediaStream) { - this.mediaStream.getTracks().forEach((track) => track.stop()); - this.mediaStream = null; - } - if (this.webSocket) { + // Detach first: a late close event from this socket must not tear down the next one. + this.webSocket.onopen = null; + this.webSocket.onerror = null; + this.webSocket.onclose = null; if ( this.webSocket.readyState === WebSocket.OPEN || this.webSocket.readyState === WebSocket.CONNECTING @@ -145,20 +313,30 @@ export class AudioRecorderService { } private async startAudioProcessing(): Promise { - if (!this.mediaStream) return; + if (!this.micStream) return; try { const AudioCtx = window.AudioContext || (window as unknown as Window & { webkitAudioContext: typeof AudioContext }) .webkitAudioContext; - this.audioContext = new AudioCtx(); - const source = this.audioContext.createMediaStreamSource(this.mediaStream); - - // Small analyser tap for the UI level meter (real waveform, no fakery). - this.analyser = this.audioContext.createAnalyser(); + const context = new AudioCtx(); + this.audioContext = context; + + // Every source sums into one mono mixer; the stereo meeting audio is down-mixed + // (speakers interpretation) so the rest of the chain sees a single channel. + const mixer = context.createGain(); + mixer.channelCount = 1; + mixer.channelCountMode = 'explicit'; + mixer.channelInterpretation = 'speakers'; + this.mixer = mixer; + context.createMediaStreamSource(this.micStream).connect(mixer); + this.connectMeetingAudio(); + + // Small analyser tap for the UI level meter (real waveform of the mix, no fakery). + this.analyser = context.createAnalyser(); this.analyser.fftSize = 64; - source.connect(this.analyser); + mixer.connect(this.analyser); this.startLevelMeter(); // Define AudioWorklet inline to keep code self-contained and run on audio thread @@ -177,14 +355,18 @@ export class AudioRecorderService { const blob = new Blob([workletCode], { type: 'application/javascript' }); const blobURL = URL.createObjectURL(blob); - await this.audioContext.audioWorklet.addModule(blobURL); + await context.audioWorklet.addModule(blobURL); URL.revokeObjectURL(blobURL); + // Paused or stopped while the module loaded: this graph is already torn down. + if (this.audioContext !== context) return; - this.audioWorkletNode = new AudioWorkletNode(this.audioContext, 'audio-processor'); - source.connect(this.audioWorkletNode); - this.audioWorkletNode.connect(this.audioContext.destination); + this.audioWorkletNode = new AudioWorkletNode(context, 'audio-processor'); + mixer.connect(this.audioWorkletNode); + // The processor never writes its output, so this only keeps the node pulled by + // the render graph: the speakers get silence, never an echo of the mix. + this.audioWorkletNode.connect(context.destination); - const inRate = this.audioContext.sampleRate; + const inRate = context.sampleRate; const outRate = 16000; const CHUNK_SIZE = 2048; // 2048 samples = 4096 bytes (since 1 sample is 2 bytes/16-bit) diff --git a/src/app/core/audio/audio-source.spec.ts b/src/app/core/audio/audio-source.spec.ts new file mode 100644 index 0000000..d3c6429 --- /dev/null +++ b/src/app/core/audio/audio-source.spec.ts @@ -0,0 +1,64 @@ +import { isCaptureDismissed, supportsMeetingAudio } from './audio-source'; + +/** A navigator stub: optional getDisplayMedia and optional UA Client Hints. */ +function fakeNavigator(options: { + displayMedia?: boolean; + brands?: string[]; + mobile?: boolean; + mediaDevices?: boolean; +}): Navigator { + const { displayMedia = true, brands, mobile = false, mediaDevices = true } = options; + return { + mediaDevices: mediaDevices + ? { getDisplayMedia: displayMedia ? () => Promise.resolve() : undefined } + : undefined, + userAgentData: brands ? { brands: brands.map((brand) => ({ brand })), mobile } : undefined, + } as unknown as Navigator; +} + +describe('supportsMeetingAudio', () => { + it('is available on desktop Chrome and Edge', () => { + expect( + supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome', 'Not.A/Brand'] })), + ).toBe(true); + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Microsoft Edge'] }))).toBe( + true, + ); + }); + + it('is unavailable on Firefox and Safari (no UA Client Hints, no display audio)', () => { + expect(supportsMeetingAudio(fakeNavigator({}))).toBe(false); + }); + + it('is unavailable on mobile Chromium', () => { + expect( + supportsMeetingAudio(fakeNavigator({ brands: ['Chromium', 'Google Chrome'], mobile: true })), + ).toBe(false); + }); + + it('is unavailable without getDisplayMedia or mediaDevices', () => { + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], displayMedia: false }))).toBe( + false, + ); + expect(supportsMeetingAudio(fakeNavigator({ brands: ['Chromium'], mediaDevices: false }))).toBe( + false, + ); + }); + + it('is unavailable on non-Chromium engines exposing client hints', () => { + expect(supportsMeetingAudio(fakeNavigator({ brands: ['SomeOtherEngine'] }))).toBe(false); + }); +}); + +describe('isCaptureDismissed', () => { + it('treats a closed picker or a denied prompt as a dismissal', () => { + expect(isCaptureDismissed(new DOMException('Permission denied', 'NotAllowedError'))).toBe(true); + expect(isCaptureDismissed(new DOMException('Aborted', 'AbortError'))).toBe(true); + }); + + it('reports real failures', () => { + expect(isCaptureDismissed(new DOMException('Busy', 'NotReadableError'))).toBe(false); + expect(isCaptureDismissed(new TypeError('bad constraints'))).toBe(false); + expect(isCaptureDismissed(null)).toBe(false); + }); +}); diff --git a/src/app/core/audio/audio-source.ts b/src/app/core/audio/audio-source.ts new file mode 100644 index 0000000..919f90a --- /dev/null +++ b/src/app/core/audio/audio-source.ts @@ -0,0 +1,71 @@ +/** + * Where the recorder takes its audio from: `mic` for face-to-face meetings, or + * `meeting` for virtual calls (Zoom / Meet / Teams), which mixes the microphone + * with the audio of a shared tab, window or screen. + */ +export type AudioSource = 'mic' | 'meeting'; + +/** Chrome's `suppressLocalAudioPlayback` audio constraint (not in lib.dom yet). */ +interface DisplayAudioConstraints extends MediaTrackConstraints { + suppressLocalAudioPlayback?: boolean; +} + +/** Chrome's display-capture extensions on top of the standard options (not in lib.dom yet). */ +interface MeetingDisplayMediaOptions extends Omit { + audio: DisplayAudioConstraints; + systemAudio?: 'include' | 'exclude'; + windowAudio?: 'exclude' | 'window' | 'system'; + selfBrowserSurface?: 'include' | 'exclude'; + surfaceSwitching?: 'include' | 'exclude'; +} + +/** User-Agent Client Hints, exposed by Chromium-based browsers only (not in lib.dom yet). */ +interface NavigatorUAData { + readonly brands: readonly { readonly brand: string }[]; + readonly mobile: boolean; +} + +/** + * The screen-share picker request for meeting audio. Chrome rejects audio-only + * requests, so `video` stays true (the recorder stops that track right away). + * The meeting audio is already clean digital audio, so the voice-call + * processing is disabled; the tab keeps playing locally so the analyst still + * hears the call. ReqsAI's own tab is hidden from the picker. + */ +export const MEETING_DISPLAY_MEDIA_OPTIONS: MeetingDisplayMediaOptions = { + video: true, + audio: { + echoCancellation: false, + noiseSuppression: false, + autoGainControl: false, + suppressLocalAudioPlayback: false, + }, + systemAudio: 'include', + windowAudio: 'system', + selfBrowserSurface: 'exclude', + surfaceSwitching: 'include', +}; + +/** + * True when this browser can capture a meeting's audio through the screen-share + * picker: desktop Chromium (Chrome, Edge…). Firefox and Safari implement + * `getDisplayMedia` without tab/system audio, and mobile browsers lack it, so + * the "virtual meeting" source is offered on desktop Chromium only. + */ +export function supportsMeetingAudio(nav: Navigator | undefined = globalThis.navigator): boolean { + if (typeof nav?.mediaDevices?.getDisplayMedia !== 'function') return false; + const uaData = (nav as Navigator & { userAgentData?: NavigatorUAData }).userAgentData; + if (!uaData || uaData.mobile) return false; + return uaData.brands.some((b) => b.brand === 'Chromium'); +} + +/** + * True when a capture request failed because the user dismissed the picker or + * the permission prompt (`NotAllowedError`, or `AbortError` in some browsers), + * as opposed to a real failure worth reporting. + */ +export function isCaptureDismissed(err: unknown): boolean { + return ( + err instanceof DOMException && (err.name === 'NotAllowedError' || err.name === 'AbortError') + ); +} diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts new file mode 100644 index 0000000..29e34e8 --- /dev/null +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslocoTestingModule } from '@jsverse/transloco'; +import { AudioSourcePicker } from './audio-source-picker'; +import { AudioSource } from '../../../../core/audio/audio-source'; + +describe('AudioSourcePicker', () => { + function render( + inputs: { value?: AudioSource; meetingSupported?: boolean; disabled?: boolean } = {}, + ): { fixture: ComponentFixture; el: HTMLElement } { + TestBed.configureTestingModule({ + imports: [AudioSourcePicker, TranslocoTestingModule.forRoot({ langs: { en: {} } })], + }); + const fixture = TestBed.createComponent(AudioSourcePicker); + fixture.componentRef.setInput('value', inputs.value ?? 'mic'); + fixture.componentRef.setInput('meetingSupported', inputs.meetingSupported ?? true); + fixture.componentRef.setInput('disabled', inputs.disabled ?? false); + fixture.detectChanges(); + return { fixture, el: fixture.nativeElement as HTMLElement }; + } + + function option(el: HTMLElement, source: AudioSource): HTMLButtonElement { + return el.querySelector(`[data-testid="audio-source-${source}"]`) as HTMLButtonElement; + } + + it('marks the current source as pressed', () => { + const { el } = render({ value: 'meeting' }); + + expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true'); + expect(option(el, 'mic').getAttribute('aria-pressed')).toBe('false'); + }); + + it('switches to the virtual meeting source', () => { + const { fixture, el } = render(); + const changes: AudioSource[] = []; + fixture.componentInstance.value.subscribe((v) => changes.push(v)); + + option(el, 'meeting').click(); + fixture.detectChanges(); + + expect(changes).toEqual(['meeting']); + expect(option(el, 'meeting').getAttribute('aria-pressed')).toBe('true'); + }); + + it('keeps the virtual option visible but inert where meeting audio is unsupported', () => { + const { fixture, el } = render({ meetingSupported: false }); + let unavailable = 0; + fixture.componentInstance.unavailablePicked.subscribe(() => unavailable++); + + const meeting = option(el, 'meeting'); + expect(meeting.getAttribute('aria-disabled')).toBe('true'); + expect(meeting.getAttribute('title')).toContain('discovery.source.unsupported'); + + meeting.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toBe('mic'); + expect(unavailable).toBe(1); + }); + + it('ignores clicks while locked by a live session', () => { + const { fixture, el } = render({ disabled: true }); + + option(el, 'meeting').click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.value()).toBe('mic'); + expect(option(el, 'mic').getAttribute('aria-disabled')).toBe('true'); + }); +}); diff --git a/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts new file mode 100644 index 0000000..d8f8039 --- /dev/null +++ b/src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts @@ -0,0 +1,85 @@ +import { ChangeDetectionStrategy, Component, input, model, output } from '@angular/core'; +import { TranslocoPipe } from '@jsverse/transloco'; +import { provideIcons } from '@ng-icons/core'; +import { lucideMic, lucideMonitorSpeaker } from '@ng-icons/lucide'; +import { AudioSource } from '../../../../core/audio/audio-source'; +import { HlmIcon } from '../../../../shared/ui'; + +interface SourceOption { + value: AudioSource; + icon: string; + label: string; +} + +/** + * Compact segmented control next to the record button: where the next + * recording takes its audio from — the microphone (in person) or the + * microphone plus the meeting's audio (virtual call). Two-way bound via + * `[(value)]`. Where the browser cannot capture meeting audio the virtual + * option stays visible but inert, with the reason as its tooltip; tapping it + * emits `unavailablePicked` so touch users get the reason too. + */ +@Component({ + selector: 'app-audio-source-picker', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmIcon, TranslocoPipe], + viewProviders: [provideIcons({ lucideMic, lucideMonitorSpeaker })], + template: ` +
+ @for (opt of options; track opt.value) { + @let unavailable = opt.value === 'meeting' && !meetingSupported(); + + } +
+ `, +}) +export class AudioSourcePicker { + readonly value = model('mic'); + /** False where the browser cannot capture meeting audio (the virtual option goes inert). */ + readonly meetingSupported = input(true); + /** Locks the choice (e.g. while a session is live). */ + readonly disabled = input(false); + readonly unavailablePicked = output(); + + protected readonly options: SourceOption[] = [ + { value: 'mic', icon: 'lucideMic', label: 'discovery.source.mic' }, + { value: 'meeting', icon: 'lucideMonitorSpeaker', label: 'discovery.source.meeting' }, + ]; + + protected choose(source: AudioSource): void { + if (this.disabled()) return; + if (source === 'meeting' && !this.meetingSupported()) { + this.unavailablePicked.emit(); + return; + } + this.value.set(source); + } + + protected optionClass(source: AudioSource, unavailable: boolean): string { + if (this.disabled() || unavailable) { + return this.value() === source + ? 'cursor-not-allowed bg-secondary text-foreground opacity-60' + : 'cursor-not-allowed text-muted-foreground opacity-50'; + } + return this.value() === source + ? 'cursor-pointer bg-secondary text-foreground' + : 'cursor-pointer text-muted-foreground hover:text-foreground'; + } +} diff --git a/src/app/features/discovery/components/session-bar/session-bar.spec.ts b/src/app/features/discovery/components/session-bar/session-bar.spec.ts index 21395a0..a1c202f 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.spec.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.spec.ts @@ -1,9 +1,11 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; import { TranslocoTestingModule } from '@jsverse/transloco'; +import { vi } from 'vitest'; import { SessionBar } from './session-bar'; import { SessionRecordingService } from '../../data/session-recording.service'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; +import { AudioSource } from '../../../../core/audio/audio-source'; import { DiscoverySessionResponse, SessionStatus } from '../../data/discovery.models'; function session(status: SessionStatus): DiscoverySessionResponse { @@ -31,18 +33,23 @@ class FakeRecordingService { class FakeAudioRecorderService { readonly levels = signal([]); + readonly source = signal('mic'); + readonly meetingAudio = signal(false); + readonly shareMeetingAudio = vi.fn(() => Promise.resolve(true)); } describe('SessionBar', () => { let recording: FakeRecordingService; + let recorder: FakeAudioRecorderService; function render(): { fixture: ComponentFixture; el: HTMLElement } { recording = new FakeRecordingService(); + recorder = new FakeAudioRecorderService(); TestBed.configureTestingModule({ imports: [SessionBar, TranslocoTestingModule.forRoot({ langs: { en: {} } })], providers: [ { provide: SessionRecordingService, useValue: recording }, - { provide: AudioRecorderService, useValue: new FakeAudioRecorderService() }, + { provide: AudioRecorderService, useValue: recorder }, ], }); const fixture = TestBed.createComponent(SessionBar); @@ -103,4 +110,39 @@ describe('SessionBar', () => { expect(el.querySelector('[data-testid="session-bar-resume"]')).not.toBeNull(); expect(el.querySelector('[data-testid="session-bar-pause"]')).toBeNull(); }); + + describe('virtual meeting source', () => { + it('shows no meeting controls for an in-person recording', () => { + const { el } = render(); + + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).toBeNull(); + expect(el.querySelector('[data-testid="session-bar-reshare"]')).toBeNull(); + }); + + it('shows the meeting-audio badge while the meeting audio is captured', () => { + const { fixture, el } = render(); + recorder.source.set('meeting'); + recorder.meetingAudio.set(true); + fixture.detectChanges(); + + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).not.toBeNull(); + expect(el.querySelector('[data-testid="session-bar-reshare"]')).toBeNull(); + }); + + it('offers to share the meeting audio again after "Stop sharing"', () => { + const { fixture, el } = render(); + recorder.source.set('meeting'); + recorder.meetingAudio.set(false); + fixture.detectChanges(); + + const reshare = el.querySelector('[data-testid="session-bar-reshare"]') as HTMLButtonElement; + expect(reshare).not.toBeNull(); + expect(reshare.getAttribute('aria-label')).toBeTruthy(); + expect(el.querySelector('[data-testid="session-bar-meeting-audio"]')).toBeNull(); + + reshare.click(); + + expect(recorder.shareMeetingAudio).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/src/app/features/discovery/components/session-bar/session-bar.ts b/src/app/features/discovery/components/session-bar/session-bar.ts index ff96a58..6e1d28f 100644 --- a/src/app/features/discovery/components/session-bar/session-bar.ts +++ b/src/app/features/discovery/components/session-bar/session-bar.ts @@ -1,7 +1,14 @@ import { ChangeDetectionStrategy, Component, computed, inject, output } from '@angular/core'; import { TranslocoPipe } from '@jsverse/transloco'; import { provideIcons } from '@ng-icons/core'; -import { lucideCircle, lucidePause, lucidePlay, lucideSquare } from '@ng-icons/lucide'; +import { + lucideCircle, + lucideMonitorSpeaker, + lucidePause, + lucidePlay, + lucideScreenShare, + lucideSquare, +} from '@ng-icons/lucide'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; import { SessionRecordingService } from '../../data/session-recording.service'; import { HlmButton, HlmIcon } from '../../../../shared/ui'; @@ -21,13 +28,24 @@ export function formatElapsed(ms: number): string { * The persistent session bar shown while a recording is live: status pulse, * elapsed timer, a real input-level meter (AnalyserNode-driven) and * pause/resume/stop controls. State lives in {@link SessionRecordingService}, - * so the bar renders the truth from anywhere in the app. + * so the bar renders the truth from anywhere in the app. Virtual-meeting + * recordings show a meeting-audio badge, or a "share again" action once the + * user stops sharing (the recording carries on with the mic meanwhile). */ @Component({ selector: 'app-session-bar', changeDetection: ChangeDetectionStrategy.OnPush, imports: [HlmButton, HlmIcon, TranslocoPipe], - viewProviders: [provideIcons({ lucideCircle, lucidePause, lucidePlay, lucideSquare })], + viewProviders: [ + provideIcons({ + lucideCircle, + lucideMonitorSpeaker, + lucidePause, + lucidePlay, + lucideScreenShare, + lucideSquare, + }), + ], template: ` @if (recording.session(); as session) {
@if (recording.status() === 'RECORDING') { @@ -69,6 +89,16 @@ export function formatElapsed(ms: number): string { {{ elapsed() }} + @if (recorder.source() === 'meeting' && recorder.meetingAudio()) { + + } + @if (recorder.levels().length > 0) { @if (canRecord()) { + +
@@ -694,6 +736,33 @@ export class DiscoveryChat implements OnInit { const code = this.liveLanguage(); return code ? this.languageAbbrev(code) : null; }); + /** Whether this browser can capture a virtual meeting's audio (desktop Chromium). */ + protected readonly meetingSupported = supportsMeetingAudio(); + /** + * Audio source for the next session: the user's last choice (localStorage), + * falling back to the microphone where meeting capture is unavailable. + */ + protected readonly audioSource = linkedSignal(() => + resolveAudioSource(this.storedAudioSource(), this.meetingSupported), + ); + /** Set when the unavailable "virtual meeting" option is tapped, to explain why. */ + protected readonly meetingUnavailableHint = signal(false); + /** True while the share picker / mic prompt is open, so a second click can't stack another. */ + protected readonly preparingCapture = signal(false); + /** The record button and source picker stay locked while starting or once a session is live. */ + protected readonly startLocked = computed( + () => this.recording.busy() || this.recording.isActive() || this.preparingCapture(), + ); + /** + * The composer's guidance line before recording: why the virtual option is + * unavailable, or how to share the meeting audio (and to wear headphones). + */ + protected readonly sourceHint = computed(() => { + if (!this.canRecord() || this.recording.isActive()) return null; + if (this.meetingUnavailableHint()) return 'discovery.source.unsupported'; + return this.audioSource() === 'meeting' ? 'discovery.source.hint' : null; + }); + /** Uppercased primary subtag of the editable language, for the select's mobile trigger. */ protected readonly languageAbbrevValue = computed(() => this.languageAbbrev(this.language())); @@ -776,7 +845,12 @@ export class DiscoveryChat implements OnInit { } protected async record(): Promise { - const granted = await this.recorder.requestPermission(); + if (this.startLocked()) return; + this.preparingCapture.set(true); + // Must stay the first await: the meeting source opens the screen-share + // picker, which needs this click's transient activation. + const granted = await this.recorder.requestPermission(this.audioSource()); + this.preparingCapture.set(false); if (!granted) return; const language = this.language(); this.recording.start(this.projectId(), { title: this.defaultTitle(), language }).subscribe({ @@ -918,6 +992,32 @@ export class DiscoveryChat implements OnInit { } } + /** Picks the audio source and persists it as this user's preference. */ + protected setAudioSource(source: AudioSource): void { + this.audioSource.set(source); + this.meetingUnavailableHint.set(false); + // A previous attempt's capture warning no longer applies to the new choice. + this.recorder.notice.set(null); + const userId = this.auth.user()?.id; + if (!userId) return; + try { + localStorage.setItem(audioSourceStorageKey(userId), source); + } catch { + // Storage can be unavailable (private mode / quota); the in-memory value still applies. + } + } + + /** This user's stored audio-source choice, or null when absent/unreadable. */ + private storedAudioSource(): string | null { + const userId = this.auth.user()?.id; + if (!userId) return null; + try { + return localStorage.getItem(audioSourceStorageKey(userId)); + } catch { + return null; + } + } + /** The org's configured meeting language, or null when unset. */ private projectLanguage(): string | null { const org = this.workspace.organizations().find((o) => o.id === this.auth.organizationId());