Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions scripts/test-speak-settings-listener.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
122 changes: 51 additions & 71 deletions src/renderer/src/hooks/useSpeakManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' },
Expand All @@ -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 {
Expand All @@ -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;
Expand All @@ -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<SpeakOptions>({
voice: DEFAULT_EDGE_TTS_VOICE,
rate: '+0%',
});
const [edgeTtsVoices, setEdgeTtsVoices] = useState<EdgeTtsVoice[]>([]);
const [elevenLabsVoices, setElevenLabsVoices] = useState<ElevenLabsVoice[]>([]);
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, {
Expand All @@ -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);
Expand All @@ -147,7 +146,7 @@ export function useSpeakManager({
disposed = true;
disposeSpeak();
};
}, []);
}, [applySpeakOptions]);

// Edge TTS voice list fetch
useEffect(() => {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -317,7 +297,7 @@ export function useSpeakManager({
voice,
restartCurrent: true,
});
setSpeakOptions(next);
applySpeakOptions(next);
} catch {}
return;
}
Expand All @@ -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;
Expand Down
Loading
Loading