diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bab0165..eb4d5e6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,7 +14,7 @@ import { type SourceState, type UpdateState } from '@shared/types' -import { channelsFor, MeetingRecorder, type Levels, type Source } from './audio/recorder' +import { channelsFor, MeetingRecorder, micLabel, type Levels, type Source } from './audio/recorder' import { MeetingView } from './components/MeetingView' import { Onboarding } from './components/Onboarding' import { ScreenPrompt, type ScreenChoice } from './components/ScreenPrompt' @@ -360,6 +360,7 @@ function Shell(): React.JSX.Element { mic: captureMic, system: captureSystem, micDeviceId: settings.micDeviceId, + micDeviceLabel: settings.micDeviceLabel, separate: settings.separateMic, screen: withScreen } @@ -375,6 +376,12 @@ function Shell(): React.JSX.Element { throw e } recorder.current = rec + rec.warnings.forEach((w) => ui.toast(w, 'info')) + // Encontrado por nombre con otro identificador, o ya no existe (se usa el predeterminado): + // se guarda lo que se ha usado para no repetir la búsqueda ni el aviso. + if (captureMic && rec.micDeviceId !== settings.micDeviceId) { + void saveSettings({ ...settings, micDeviceId: rec.micDeviceId }) + } setScreenOn(withScreen) setLiveNotice(null) setPaused(false) @@ -722,7 +729,9 @@ function Shell(): React.JSX.Element { captureSystem={captureSystem} onCaptureMic={setCaptureMic} onCaptureSystem={setCaptureSystem} - onMicDevice={(micDeviceId) => void saveSettings({ ...settings, micDeviceId })} + onMicDevice={(micDeviceId) => + void micLabel(micDeviceId).then((micDeviceLabel) => saveSettings({ ...settings, micDeviceId, micDeviceLabel })) + } onScreen={(patch) => void saveSettings({ ...settings, ...patch })} screenOn={screenOn} onToggleScreen={() => void toggleScreen()} diff --git a/src/renderer/src/audio/recorder.ts b/src/renderer/src/audio/recorder.ts index 69691e8..cec18b1 100644 --- a/src/renderer/src/audio/recorder.ts +++ b/src/renderer/src/audio/recorder.ts @@ -5,6 +5,8 @@ export interface CapturePlan { mic: boolean system: boolean micDeviceId: string + /** Nombre del micrófono elegido: sirve para encontrarlo si su identificador cambia. */ + micDeviceLabel?: string /** Transcribir el micrófono aparte (siempre "yo") y el sistema aparte. */ separate: boolean /** Grabar también la pantalla elegida (screen.mp4, con el audio mezclado). */ @@ -46,6 +48,10 @@ export class MeetingRecorder { private meetingId = '' private mixStream: MediaStream | null = null private screenTrack: MediaStreamTrack | null = null + /** Avisos que no impiden grabar (p. ej. micrófono elegido no disponible). */ + warnings: string[] = [] + /** Micrófono que se está usando de verdad. */ + micDeviceId = '' constructor(private onLevels?: (l: Levels) => void) {} @@ -78,9 +84,12 @@ export class MeetingRecorder { } } if (p.mic) { + const mic = await resolveMic(p.micDeviceId, p.micDeviceLabel ?? '') + if (mic.warning) this.warnings.push(mic.warning) + this.micDeviceId = mic.deviceId ?? '' micStream = await navigator.mediaDevices.getUserMedia({ audio: { - deviceId: p.micDeviceId ? { exact: p.micDeviceId } : undefined, + deviceId: mic.deviceId ? { exact: mic.deviceId } : undefined, echoCancellation: true, noiseSuppression: true, autoGainControl: true @@ -264,3 +273,26 @@ export class MeetingRecorder { return duration } } + +/** + * Encuentra el micrófono elegido. Su identificador depende de desde dónde se abra la + * app (instalada o en desarrollo) y puede cambiar al reinstalar drivers, así que si no + * aparece se busca por nombre; si tampoco, se usa el predeterminado y se avisa. + */ +export async function resolveMic(id: string, label: string): Promise<{ deviceId?: string; warning?: string }> { + if (!id) return {} + const inputs = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === 'audioinput') + if (inputs.some((d) => d.deviceId === id)) return { deviceId: id } + const byLabel = label ? inputs.find((d) => d.label === label) : undefined + if (byLabel) return { deviceId: byLabel.deviceId } + return { + warning: `No se encuentra el micrófono elegido${label ? ` (${label})` : ''}: se usa el predeterminado del sistema. Puedes cambiarlo en la flecha junto a Micrófono.` + } +} + +/** Nombre de un micrófono por su identificador (para guardarlo junto a él). */ +export async function micLabel(id: string): Promise { + if (!id) return '' + const devices = await navigator.mediaDevices.enumerateDevices() + return devices.find((d) => d.kind === 'audioinput' && d.deviceId === id)?.label ?? '' +} diff --git a/src/renderer/src/components/Onboarding.tsx b/src/renderer/src/components/Onboarding.tsx index 6d76b71..188abe4 100644 --- a/src/renderer/src/components/Onboarding.tsx +++ b/src/renderer/src/components/Onboarding.tsx @@ -19,6 +19,7 @@ import type { ApiKeys, FinalProvider, KeySource, LiveProvider, LlmProvider, Loca import { finalProviderFor, languageNames, LANGUAGES, liveProviderFor } from '@shared/languages' import { Field, Logo, quick, soft, spring, Toggle } from './ui' import { MultiSelect } from './Select' +import { micLabel } from '../audio/recorder' import { keyInfo, KeyInput, KeyStatus, useKeyChecks, type KeyState } from './keys' import { languageHint, MicField, SEPARATE_MIC_DESC } from './SettingsView' import { LocalAiCard, localAiSummary, useLocalAi } from './LocalAi' @@ -370,7 +371,10 @@ export function Onboarding({ settings, closable, onSave, onFinish, onClose }: Pr

Tu micrófono

- set({ micDeviceId })} /> + void micLabel(micDeviceId).then((micDeviceLabel) => set({ micDeviceId, micDeviceLabel }))} + />

Pulsa Probar micrófono y habla: la barra debería moverse con tu voz.

diff --git a/src/renderer/src/components/Player.tsx b/src/renderer/src/components/Player.tsx index f46bd03..aa316c5 100644 --- a/src/renderer/src/components/Player.tsx +++ b/src/renderer/src/components/Player.tsx @@ -156,12 +156,12 @@ export const Player = forwardRef(function Player( } const toggleFullscreen = (): void => { - if (document.fullscreenElement) void document.exitFullscreen() + if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}) else if (withVideo) void stage.current?.requestFullscreen() } const togglePip = (): void => { - if (document.pictureInPictureElement) void document.exitPictureInPicture() + if (document.pictureInPictureElement) void document.exitPictureInPicture().catch(() => {}) else void screen.current?.requestPictureInPicture().catch(() => {}) } @@ -264,8 +264,8 @@ export const Player = forwardRef(function Player( // Si se esconde el vídeo, fuera de pantalla completa y de PiP. useEffect(() => { if (withVideo) return - if (document.fullscreenElement === stage.current) void document.exitFullscreen() - if (document.pictureInPictureElement === screen.current) void document.exitPictureInPicture() + if (document.fullscreenElement === stage.current) void document.exitFullscreen().catch(() => {}) + if (document.pictureInPictureElement === screen.current) void document.exitPictureInPicture().catch(() => {}) }, [withVideo]) const readBuffered = (): void => { diff --git a/src/renderer/src/components/SettingsView.tsx b/src/renderer/src/components/SettingsView.tsx index 35c2f3b..47ff401 100644 --- a/src/renderer/src/components/SettingsView.tsx +++ b/src/renderer/src/components/SettingsView.tsx @@ -36,6 +36,7 @@ import { CostEstimate } from './CostEstimate' import { GlossarySettings } from './GlossarySettings' import { KEYS, KeyInput, KeyStatus, useKeyChecks, type KeyInfo } from './keys' import { ComboInput, MultiSelect, Select } from './Select' +import { micLabel } from '../audio/recorder' import { knownModels, llmPrice } from '@shared/pricing' import { finalProviderFor, languageNames, LANGUAGES, liveProviderFor, needsElevenLabs } from '@shared/languages' @@ -753,7 +754,10 @@ function AudioSettings({ s, set }: { s: Settings; set: (p: Partial) => <>

Micrófono

- set({ micDeviceId })} /> + void micLabel(micDeviceId).then((micDeviceLabel) => set({ micDeviceId, micDeviceLabel }))} + />

Separación de tu voz

diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 3eb5df5..88b5d38 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -39,11 +39,19 @@ export function initials(label: string): string { return (parts[0][0] + (parts[1]?.[0] ?? '')).toUpperCase() } +const CAPTURE_ERRORS: Record = { + OverconstrainedError: 'El micrófono elegido no está disponible. Elige otro en la flecha junto a Micrófono.', + NotFoundError: 'No se ha encontrado ningún micrófono. Conecta uno o desactiva Micrófono.', + NotReadableError: 'No se puede usar el micrófono: puede que otra aplicación lo esté usando en exclusiva.', + NotAllowedError: 'Windows no ha dado permiso para capturar el audio. Revisa la privacidad del micrófono en Configuración de Windows.', + AbortError: 'Se ha interrumpido la captura de audio. Vuelve a intentarlo.' +} + export function errorMessage(e: unknown): string { - return String((e as Error)?.message ?? e).replace( - /^Error invoking remote method '[^']+': (Error: )?/, - '' - ) + const err = e as Error | undefined + const text = String(err?.message ?? e ?? '').replace(/^Error invoking remote method '[^']+': (Error: )?/, '').trim() + if (err?.name && CAPTURE_ERRORS[err.name]) return text ? `${CAPTURE_ERRORS[err.name]} (${text})` : CAPTURE_ERRORS[err.name] + return text || `Ha ocurrido un error inesperado${err?.name && err.name !== 'Error' ? ` (${err.name})` : ''}.` } /** Minúsculas y sin tildes, carácter a carácter (mantiene la correspondencia de posiciones). */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 6b584ce..32f0a3e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -233,6 +233,8 @@ export interface Settings { /** Idiomas que se hablan en las reuniones. Vacío = detectar cualquiera. */ languages: string[] micDeviceId: string + /** Nombre del micrófono elegido, para reconocerlo si cambia su identificador. */ + micDeviceLabel?: string /** Transcribe el micrófono aparte y lo etiqueta siempre como "yo". */ separateMic: boolean /** Grabar también la pantalla y cuál (identificador del monitor). */