diff --git a/scripts/test-speak-settings-listener.mjs b/scripts/test-speak-settings-listener.mjs new file mode 100644 index 00000000..d67c4011 --- /dev/null +++ b/scripts/test-speak-settings-listener.mjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const hookSource = fs.readFileSync('src/renderer/src/hooks/useSpeakManager.ts', 'utf8'); + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(filePath); + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + const sandbox = { + module, + exports: module.exports, + require, + console, + Promise, + String, + RegExp, + }; + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +function makeRecorder(currentVoice = 'en-US-EricNeural') { + const calls = []; + const configuredModels = []; + const configuredEdgeVoices = []; + const appliedOptions = []; + + return { + calls, + configuredModels, + configuredEdgeVoices, + appliedOptions, + options: { + getCurrentVoice: () => currentVoice, + setConfiguredTtsModel: (value) => configuredModels.push(value), + setConfiguredEdgeTtsVoice: (value) => configuredEdgeVoices.push(value), + updateSpeakOptions: async (patch) => { + calls.push(patch); + currentVoice = patch.voice; + return { voice: patch.voice, rate: '+0%' }; + }, + setSpeakOptions: (next) => { + appliedOptions.push(next); + }, + }, + }; +} + +const { + applySpeakSettings, + buildElevenLabsSpeakModel, + parseElevenLabsSpeakModel, + resolveSpeakSettings, +} = loadTsModule('src/renderer/src/utils/speak-settings-sync.ts'); + +function assertJsonEqual(actual, expected) { + assert.equal(JSON.stringify(actual), JSON.stringify(expected)); +} + +test('speak settings sync uses initial load and settings-updated listener, not polling', () => { + assert.match(hookSource, /window\.electron\.getSettings\(\)\.then\(syncFromSettings\)/); + assert.match(hookSource, /window\.electron\.onSettingsUpdated\?\.\(\(settings\) =>/); + assert.match(hookSource, /cleanupSettings\?\.\(\)/); + assert.doesNotMatch(hookSource, /setInterval\(\s*syncFromSettings/); + assert.doesNotMatch(hookSource, /clearInterval\(/); +}); + +test('initial settings load applies configured Edge TTS voice once', async () => { + const recorder = makeRecorder('en-US-EricNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: 'edge-tts', + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assert.deepEqual(recorder.configuredModels, ['edge-tts']); + assert.deepEqual(recorder.configuredEdgeVoices, ['en-US-GuyNeural']); + assertJsonEqual(recorder.calls, [{ voice: 'en-US-GuyNeural', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'en-US-GuyNeural', rate: '+0%' }]); +}); + +test('live settings update switches to ElevenLabs voice without extra idle updates', async () => { + const recorder = makeRecorder('en-US-GuyNeural'); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + await applySpeakSettings({ + ai: { + textToSpeechModel: buildElevenLabsSpeakModel('elevenlabs-multilingual-v2', 'AZnzlk1XvdvUeBnXmlld'), + edgeTtsVoice: 'en-US-GuyNeural', + }, + }, recorder.options); + + assertJsonEqual(recorder.calls, [{ voice: 'AZnzlk1XvdvUeBnXmlld', restartCurrent: false }]); + assertJsonEqual(recorder.appliedOptions, [{ voice: 'AZnzlk1XvdvUeBnXmlld', rate: '+0%' }]); +}); + +test('ElevenLabs model parser and resolver preserve model and default voice behavior', () => { + assertJsonEqual(parseElevenLabsSpeakModel('elevenlabs-turbo-v2@pNInz6obpgDQGcFmaJgB'), { + model: 'elevenlabs-turbo-v2', + voiceId: 'pNInz6obpgDQGcFmaJgB', + }); + assert.equal(resolveSpeakSettings({ ai: { textToSpeechModel: 'elevenlabs-multilingual-v2' } }).targetVoice, '21m00Tcm4TlvDq8ikWAM'); +}); diff --git a/src/renderer/src/hooks/useSpeakManager.ts b/src/renderer/src/hooks/useSpeakManager.ts index 1e3f8cb3..3dfb070a 100644 --- a/src/renderer/src/hooks/useSpeakManager.ts +++ b/src/renderer/src/hooks/useSpeakManager.ts @@ -10,8 +10,8 @@ * - handleSpeakVoiceChange / handleSpeakRateChange: persist user selections to settings * - Opens a detached portal window for the speak overlay via useDetachedPortalWindow * - * Polls speak status from the main process while the overlay is visible, and syncs - * the configured voice from settings each time the overlay opens. + * Listens for speak status updates from the main process, and syncs + * the configured voice from initial settings plus live settings updates. */ import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; @@ -23,9 +23,19 @@ import { getCachedElevenLabsVoices, setCachedElevenLabsVoices, } from '../utils/voice-cache'; +import { + applySpeakSettings, + buildElevenLabsSpeakModel, + DEFAULT_EDGE_TTS_VOICE, + DEFAULT_ELEVENLABS_VOICE_ID, + DEFAULT_TTS_MODEL, + parseElevenLabsSpeakModel, + type SpeakOptions, + type SpeakSettingsSnapshot, +} from '../utils/speak-settings-sync'; const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ - { id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel' }, + { id: DEFAULT_ELEVENLABS_VOICE_ID, label: 'Rachel' }, { id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi' }, { id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella' }, { id: 'ErXwobaYiN019PkySvjV', label: 'Antoni' }, @@ -36,23 +46,6 @@ const ELEVENLABS_VOICES: Array<{ id: string; label: string }> = [ { id: 'yoZ06aMxZJJ28mfd3POQ', label: 'Sam' }, ]; -const DEFAULT_ELEVENLABS_VOICE_ID = ELEVENLABS_VOICES[0].id; - -function parseElevenLabsSpeakModel(raw: string): { model: string; voiceId: string } { - const value = String(raw || '').trim(); - const explicitVoice = /@([A-Za-z0-9]{8,})$/.exec(value)?.[1]; - const modelOnly = explicitVoice ? value.replace(/@[A-Za-z0-9]{8,}$/, '') : value; - const model = modelOnly.startsWith('elevenlabs-') ? modelOnly : 'elevenlabs-multilingual-v2'; - const voiceId = explicitVoice || DEFAULT_ELEVENLABS_VOICE_ID; - return { model, voiceId }; -} - -function buildElevenLabsSpeakModel(model: string, voiceId: string): string { - const normalizedModel = String(model || '').trim() || 'elevenlabs-multilingual-v2'; - const normalizedVoice = String(voiceId || '').trim() || DEFAULT_ELEVENLABS_VOICE_ID; - return `${normalizedModel}@${normalizedVoice}`; -} - // ─── Types ─────────────────────────────────────────────────────────── export interface SpeakStatus { @@ -71,7 +64,7 @@ export interface UseSpeakManagerOptions { export interface UseSpeakManagerReturn { speakStatus: SpeakStatus; - speakOptions: { voice: string; rate: string }; + speakOptions: SpeakOptions; edgeTtsVoices: EdgeTtsVoice[]; configuredEdgeTtsVoice: string; configuredTtsModel: string; @@ -98,18 +91,24 @@ export function useSpeakManager({ index: 0, total: 0, }); - const [speakOptions, setSpeakOptions] = useState<{ voice: string; rate: string }>({ - voice: 'en-US-EricNeural', + const [speakOptions, setSpeakOptions] = useState({ + voice: DEFAULT_EDGE_TTS_VOICE, rate: '+0%', }); const [edgeTtsVoices, setEdgeTtsVoices] = useState([]); const [elevenLabsVoices, setElevenLabsVoices] = useState([]); - const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState('en-US-EricNeural'); - const [configuredTtsModel, setConfiguredTtsModel] = useState('edge-tts'); + const [configuredEdgeTtsVoice, setConfiguredEdgeTtsVoice] = useState(DEFAULT_EDGE_TTS_VOICE); + const [configuredTtsModel, setConfiguredTtsModel] = useState(DEFAULT_TTS_MODEL); + const speakOptionsVoiceRef = useRef(speakOptions.voice); const speakSessionShownRef = useRef(false); const pauseToggleInFlightRef = useRef(false); + const applySpeakOptions = useCallback((next: SpeakOptions) => { + speakOptionsVoiceRef.current = next.voice; + setSpeakOptions(next); + }, []); + // ── Portal ───────────────────────────────────────────────────────── const speakPortalTarget = useDetachedPortalWindow(showSpeak, { @@ -135,7 +134,7 @@ export function useSpeakManager({ useEffect(() => { let disposed = false; window.electron.speakGetOptions().then((options) => { - if (!disposed && options) setSpeakOptions(options); + if (!disposed && options) applySpeakOptions(options); }).catch(() => {}); window.electron.speakGetStatus().then((status) => { if (!disposed && status) setSpeakStatus(status); @@ -147,7 +146,7 @@ export function useSpeakManager({ disposed = true; disposeSpeak(); }; - }, []); + }, [applySpeakOptions]); // Edge TTS voice list fetch useEffect(() => { @@ -205,49 +204,30 @@ export function useSpeakManager({ }; }, [configuredTtsModel]); - // Sync configured voice from settings whenever they change + // Sync configured voice from initial settings and live settings updates useEffect(() => { let disposed = false; - const syncFromSettings = async () => { - try { - const settings = await window.electron.getSettings(); - if (disposed) return; - - const ttsModel = String(settings.ai?.textToSpeechModel || 'edge-tts'); - const edgeVoice = String(settings.ai?.edgeTtsVoice || 'en-US-EricNeural'); - - setConfiguredTtsModel(ttsModel); - setConfiguredEdgeTtsVoice(edgeVoice); - - // Also sync speakOptions to match settings - const usingElevenLabs = ttsModel.startsWith('elevenlabs-'); - const targetVoice = usingElevenLabs - ? parseElevenLabsSpeakModel(ttsModel).voiceId - : edgeVoice; - - if (targetVoice && targetVoice !== speakOptions.voice) { - const next = await window.electron.speakUpdateOptions({ - voice: targetVoice, - restartCurrent: false, - }); - setSpeakOptions(next); - } - } catch { - // Ignore errors - } + const syncFromSettings = (settings: SpeakSettingsSnapshot | null | undefined) => { + void applySpeakSettings(settings, { + getCurrentVoice: () => speakOptionsVoiceRef.current, + setConfiguredTtsModel, + setConfiguredEdgeTtsVoice, + updateSpeakOptions: (patch) => window.electron.speakUpdateOptions(patch), + setSpeakOptions: applySpeakOptions, + isDisposed: () => disposed, + }).catch(() => {}); }; - - // Initial sync - syncFromSettings(); - - // Poll for settings changes every 2 seconds while widget is relevant - const interval = setInterval(syncFromSettings, 2000); - + + window.electron.getSettings().then(syncFromSettings).catch(() => {}); + const cleanupSettings = window.electron.onSettingsUpdated?.((settings) => { + syncFromSettings(settings); + }); + return () => { disposed = true; - clearInterval(interval); + cleanupSettings?.(); }; - }, []); + }, [applySpeakOptions]); // Auto-sync configured voice when speak view opens useEffect(() => { @@ -266,9 +246,9 @@ export function useSpeakManager({ voice: targetVoice, restartCurrent: true, }).then((next) => { - setSpeakOptions(next); + applySpeakOptions(next); }).catch(() => {}); - }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice]); + }, [showSpeak, configuredTtsModel, configuredEdgeTtsVoice, speakOptions.voice, applySpeakOptions]); // ── Memos ────────────────────────────────────────────────────────── @@ -317,7 +297,7 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} return; } @@ -333,17 +313,17 @@ export function useSpeakManager({ voice, restartCurrent: true, }); - setSpeakOptions(next); + applySpeakOptions(next); } catch {} - }, [configuredTtsModel]); + }, [configuredTtsModel, applySpeakOptions]); const handleSpeakRateChange = useCallback(async (rate: string) => { const next = await window.electron.speakUpdateOptions({ rate, restartCurrent: true, }); - setSpeakOptions(next); - }, []); + applySpeakOptions(next); + }, [applySpeakOptions]); const handleSpeakTogglePause = useCallback(async () => { if (pauseToggleInFlightRef.current) return; diff --git a/src/renderer/src/utils/speak-settings-sync.ts b/src/renderer/src/utils/speak-settings-sync.ts new file mode 100644 index 00000000..4d742872 --- /dev/null +++ b/src/renderer/src/utils/speak-settings-sync.ts @@ -0,0 +1,77 @@ +export const DEFAULT_TTS_MODEL = 'edge-tts'; +export const DEFAULT_EDGE_TTS_VOICE = 'en-US-EricNeural'; +export const DEFAULT_ELEVENLABS_VOICE_ID = '21m00Tcm4TlvDq8ikWAM'; + +export interface SpeakOptions { + voice: string; + rate: string; +} + +export interface SpeakSettingsSnapshot { + ai?: { + textToSpeechModel?: string | null; + edgeTtsVoice?: string | null; + } | null; +} + +export interface ResolvedSpeakSettings { + ttsModel: string; + edgeVoice: string; + targetVoice: string; +} + +export function parseElevenLabsSpeakModel(raw: string): { model: string; voiceId: string } { + const value = String(raw || '').trim(); + const explicitVoice = /@([A-Za-z0-9]{8,})$/.exec(value)?.[1]; + const modelOnly = explicitVoice ? value.replace(/@[A-Za-z0-9]{8,}$/, '') : value; + const model = modelOnly.startsWith('elevenlabs-') ? modelOnly : 'elevenlabs-multilingual-v2'; + const voiceId = explicitVoice || DEFAULT_ELEVENLABS_VOICE_ID; + return { model, voiceId }; +} + +export function buildElevenLabsSpeakModel(model: string, voiceId: string): string { + const normalizedModel = String(model || '').trim() || 'elevenlabs-multilingual-v2'; + const normalizedVoice = String(voiceId || '').trim() || DEFAULT_ELEVENLABS_VOICE_ID; + return `${normalizedModel}@${normalizedVoice}`; +} + +export function resolveSpeakSettings(settings: SpeakSettingsSnapshot | null | undefined): ResolvedSpeakSettings { + const ttsModel = String(settings?.ai?.textToSpeechModel || DEFAULT_TTS_MODEL); + const edgeVoice = String(settings?.ai?.edgeTtsVoice || DEFAULT_EDGE_TTS_VOICE); + const targetVoice = ttsModel.startsWith('elevenlabs-') + ? parseElevenLabsSpeakModel(ttsModel).voiceId + : edgeVoice; + + return { ttsModel, edgeVoice, targetVoice }; +} + +export async function applySpeakSettings( + settings: SpeakSettingsSnapshot | null | undefined, + options: { + getCurrentVoice: () => string; + setConfiguredTtsModel: (value: string) => void; + setConfiguredEdgeTtsVoice: (value: string) => void; + updateSpeakOptions: (patch: { voice: string; restartCurrent: false }) => Promise; + setSpeakOptions: (next: SpeakOptions) => void; + isDisposed?: () => boolean; + } +): Promise { + const resolved = resolveSpeakSettings(settings); + if (options.isDisposed?.()) return resolved; + + options.setConfiguredTtsModel(resolved.ttsModel); + options.setConfiguredEdgeTtsVoice(resolved.edgeVoice); + + if (!resolved.targetVoice || resolved.targetVoice === options.getCurrentVoice()) { + return resolved; + } + + const next = await options.updateSpeakOptions({ + voice: resolved.targetVoice, + restartCurrent: false, + }); + if (!next || options.isDisposed?.()) return resolved; + + options.setSpeakOptions(next); + return resolved; +}