From 903703cdaf34c0436b18fbc26b8ff2d2c3313214 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:38:16 -0500 Subject: [PATCH 1/7] feat(discovery): mix shared meeting audio with the mic in the recorder Adds a `meeting` audio source for virtual calls (Zoom / Meet / Teams): the recorder opens the screen-share picker first (it needs the click's transient activation), keeps the shared surface's audio with voice processing off, stops the unused video track and then takes the mic. Both sources feed one mono GainNode mixer (explicit 1 channel, speakers down-mix) that drives the level meter and the PCM worklet, so `/ws/stt` still gets 16 kHz Int16 mono. - Sharing without "Share tab audio" stops the tracks and sets a notice; a dismissed picker stays idle without messages. - "Stop sharing" (audio track `ended`) disconnects the meeting audio and keeps streaming the mic with a notice; `shareMeetingAudio()` re-shares. - `pauseStreaming()` keeps the shared surface so a resume never re-opens the picker; `stopStreaming()` releases everything. - Socket handlers are detached before closing so a late close event can't tear down the next socket, and a session that fails to start releases the capture. --- public/i18n/en.json | 6 +- public/i18n/es.json | 6 +- src/app/core/audio/audio-recorder.service.ts | 262 +++++++++++++++--- src/app/core/audio/audio-source.ts | 71 +++++ .../data/session-recording.service.ts | 19 +- 5 files changed, 315 insertions(+), 49 deletions(-) create mode 100644 src/app/core/audio/audio-source.ts diff --git a/public/i18n/en.json b/public/i18n/en.json index 3639804..5f1b31e 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1095,7 +1095,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..21bd2de 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1095,7 +1095,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.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.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/data/session-recording.service.ts b/src/app/features/discovery/data/session-recording.service.ts index 2e73aab..69dc8f7 100644 --- a/src/app/features/discovery/data/session-recording.service.ts +++ b/src/app/features/discovery/data/session-recording.service.ts @@ -11,8 +11,9 @@ import { /** * Singleton owner of the active recording session's lifecycle, so navigating * away from the discovery chat never kills a recording. Pressing record - * creates a session implicitly (create → start) and begins mic streaming; - * pause/resume/stop drive both the REST lifecycle and the local microphone. + * creates a session implicitly (create → start) and begins audio streaming; + * pause/resume/stop drive both the REST lifecycle and the local capture. A + * pause keeps a shared meeting surface alive, so resuming needs no picker. * * `attach()` adopts a session that is already active elsewhere (409 * SESSION_ALREADY_ACTIVE): the bar and timer work, but this tab does not @@ -69,8 +70,9 @@ export class SessionRecordingService { /** * Implicit session start: creates the session and immediately starts it, - * then begins mic streaming. The caller should have secured mic permission - * first (so we never leave an orphan RECORDING session without audio). + * then begins audio streaming. The caller should have secured the capture + * first (so we never leave an orphan RECORDING session without audio); it is + * released again if the session cannot start. */ start( projectId: string, @@ -87,7 +89,10 @@ export class SessionRecordingService { void this.recorder.startStreaming(session.id); this.busy.set(false); }, - error: () => this.busy.set(false), + error: () => { + this.recorder.stopStreaming(); + this.busy.set(false); + }, }), ); } @@ -115,7 +120,7 @@ export class SessionRecordingService { next: (updated) => { this._session.set(updated); this.pauseTimer(); - this.recorder.stopStreaming(); + this.recorder.pauseStreaming(); this.busy.set(false); }, error: () => this.busy.set(false), @@ -168,7 +173,7 @@ export class SessionRecordingService { if (session.status !== 'RECORDING') this.resumeTimer(); } else if (status === 'PAUSED') { this.pauseTimer(); - if (this.hasMic) this.recorder.stopStreaming(); + if (this.hasMic) this.recorder.pauseStreaming(); } else { this.teardown(); return; From 6809cbb3834852a04f49d4e1ebcf22b9c9344fe1 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:38:33 -0500 Subject: [PATCH 2/7] test(discovery): cover meeting capture, fallbacks and mixing in the recorder Mocks navigator.mediaDevices to cover the picker-before-mic ordering (no await before getDisplayMedia), the display constraints, the dropped video track, the no-audio-track warning, silent picker dismissal, real capture failures, a missing getDisplayMedia, mic denial releasing the shared tab, the "Stop sharing" mic-only fallback and re-share, and pause vs. stop release. A real Web Audio graph with a fake socket checks that both sources feed the mono mixer, which drives the meter and the worklet. Also covers the desktop-Chromium support check and dismissal detection. --- .../core/audio/audio-recorder.service.spec.ts | 385 ++++++++++++++++++ src/app/core/audio/audio-source.spec.ts | 64 +++ 2 files changed, 449 insertions(+) create mode 100644 src/app/core/audio/audio-recorder.service.spec.ts create mode 100644 src/app/core/audio/audio-source.spec.ts 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-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); + }); +}); From 4a0c26f0e3adee757de8e00467b8f50d209faa37 Mon Sep 17 00:00:00 2001 From: Gutierrez Soto Jhosepmyr Orlando Date: Wed, 7 Oct 2026 00:39:01 -0500 Subject: [PATCH 3/7] feat(discovery): add an in-person / virtual-meeting audio source picker A compact segmented control next to the record button picks where the next recording takes its audio from: "In person (microphone)" or "Virtual meeting (microphone + meeting audio)". The choice is remembered per user in localStorage (try/catch) and locked while starting or once a session is live; the record click passes it to the recorder before any await so the screen-share picker keeps the click's activation, and a second click can't stack another picker. The virtual option stays visible but inert outside desktop Chromium, with the reason as tooltip and, on tap, as the composer hint. With the virtual source selected the hint explains to tick "Share tab audio" and to wear headphones; recorder notices (amber) render in the same line. --- public/i18n/en.json | 7 ++ public/i18n/es.json | 7 ++ .../audio-source-picker.ts | 85 ++++++++++++++ .../discovery/data/audio-source-preference.ts | 25 ++++ .../pages/discovery-chat/discovery-chat.ts | 108 +++++++++++++++++- 5 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 src/app/features/discovery/components/audio-source-picker/audio-source-picker.ts create mode 100644 src/app/features/discovery/data/audio-source-preference.ts diff --git a/public/i18n/en.json b/public/i18n/en.json index 5f1b31e..e3add7f 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -1082,6 +1082,13 @@ "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", diff --git a/public/i18n/es.json b/public/i18n/es.json index 21bd2de..f31a704 100644 --- a/public/i18n/es.json +++ b/public/i18n/es.json @@ -1082,6 +1082,13 @@ "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", 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/data/audio-source-preference.ts b/src/app/features/discovery/data/audio-source-preference.ts new file mode 100644 index 0000000..846d013 --- /dev/null +++ b/src/app/features/discovery/data/audio-source-preference.ts @@ -0,0 +1,25 @@ +import { AudioSource } from '../../../core/audio/audio-source'; + +/** + * Per-user audio-source preference (in person vs. virtual meeting), persisted + * in localStorage so the analyst's last choice survives reloads. Pure helpers + * so the fallback rule stays unit-testable. + */ + +const KEY_PREFIX = 'reqsai.discovery.audioSource.'; + +/** The localStorage key holding one user's last audio-source choice. */ +export function audioSourceStorageKey(userId: string): string { + return `${KEY_PREFIX}${userId}`; +} + +/** + * Resolves the source picker's initial value: a stored `meeting` choice wins + * only where this browser can capture meeting audio; anything else is `mic`. + */ +export function resolveAudioSource( + stored: string | null | undefined, + meetingSupported: boolean, +): AudioSource { + return stored === 'meeting' && meetingSupported ? 'meeting' : 'mic'; +} diff --git a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts index 57da0e0..bb38c3a 100644 --- a/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts +++ b/src/app/features/discovery/pages/discovery-chat/discovery-chat.ts @@ -24,11 +24,14 @@ import { lucideCheck, lucideCircleHelp, lucideClock, + lucideHeadphones, lucideHistory, + lucideInfo, lucideLanguages, lucideMic, lucidePanelRight, lucideSparkles, + lucideTriangleAlert, lucideX, } from '@ng-icons/lucide'; import { AuthStore } from '../../../../core/auth/auth.store'; @@ -36,6 +39,7 @@ import { WorkspaceStore } from '../../../workspace/data/workspace.store'; import { ToastService } from '../../../../shared/toast/toast.service'; import { messageForError } from '../../../../core/errors/error-message'; import { AudioRecorderService } from '../../../../core/audio/audio-recorder.service'; +import { AudioSource, supportsMeetingAudio } from '../../../../core/audio/audio-source'; import { DiscoveryChatStore, RenderBlock } from '../../data/discovery-chat.store'; import { SessionRecordingService } from '../../data/session-recording.service'; import { SpeakerDisplay } from '../../data/feed'; @@ -46,6 +50,7 @@ import { SuggestionResponse, } from '../../data/discovery.models'; import { SessionBar } from '../../components/session-bar/session-bar'; +import { AudioSourcePicker } from '../../components/audio-source-picker/audio-source-picker'; import { ActiveParticipants } from '../../components/active-participants/active-participants'; import { DecisionQueue } from '../../components/decision-queue/decision-queue'; import { SidePanel } from '../../components/side-panel/side-panel'; @@ -53,6 +58,7 @@ import { Select, SelectOption } from '../../../../shared/components/select/selec import { Modal } from '../../../../shared/components/modal/modal'; import { DISCOVERY_LANGUAGES } from '../../data/discovery-languages'; import { languageStorageKey, resolveInitialLanguage } from '../../data/language-preference'; +import { audioSourceStorageKey, resolveAudioSource } from '../../data/audio-source-preference'; import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /** @@ -60,7 +66,8 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; * chronological, read-only stream of transcript bubbles and resolved decision * cards, chunked by session; scrolling to the top lazily loads older sessions. * The composer at the bottom pairs a disabled ("coming soon") text input with - * the record button. Pending AI suggestions surface in the floating decision + * the audio-source picker (in person vs. virtual meeting) and the record + * button. Pending AI suggestions surface in the floating decision * queue; the side panel exposes the project's stories/info/glossary/constraints. */ @Component({ @@ -71,6 +78,7 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; DatePipe, TranslocoPipe, SessionBar, + AudioSourcePicker, ActiveParticipants, DecisionQueue, SidePanel, @@ -87,11 +95,14 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; lucideCheck, lucideCircleHelp, lucideClock, + lucideHeadphones, lucideHistory, + lucideInfo, lucideLanguages, lucideMic, lucidePanelRight, lucideSparkles, + lucideTriangleAlert, lucideX, }), ], @@ -501,16 +512,24 @@ import { HlmButton, HlmIcon, HlmSpinner } from '../../../../shared/ui'; /> @if (canRecord()) { + + + } + @if (recording.status() === 'RECORDING') {