feat(tts): Phase 4 — Read Page Aloud with OS-native voices
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled

Platform-dispatched TTS (spd-say + espeak-ng fallback on Linux, say on
macOS, PowerShell System.Speech on Windows) with a shared SpeakerButton
component. Tap to speak, tap again to stop; only one button speaks at
a time so two surfaces don't talk over each other. Text always travels
via argv (or a PowerShell here-string delivered through -EncodedCommand
on Windows) so user content never enters a shell string.

Mount points: DictationPage transcript footer, transcript viewer header,
per-step in MicroSteps. Settings gains a "Read aloud" accordion with
voice picker (lazy-loaded from the OS synth), rate slider 0.5-2.0x,
and a British-English test utterance.

Rust tests cover rate mapping, NaN handling, and Windows here-string
terminator safety. No pause/resume, no SSML, no cloud voices — that
stays out of scope per the Layer-1 roadmap.
This commit is contained in:
2026-04-24 16:01:47 +01:00
parent b344e8a580
commit 9f53702c7e
12 changed files with 699 additions and 7 deletions

View File

@@ -633,6 +633,47 @@
}
}
// Phase 4 Read Page Aloud. Voices are lazy-loaded on first open so
// Settings doesn't pay the `say -v ?` / PowerShell cost on every
// mount. An empty list renders as "System default" only.
let ttsVoices = $state([]);
let ttsVoicesLoaded = $state(false);
let ttsVoicesLoading = $state(false);
let ttsVoicesError = $state("");
async function refreshTtsVoices() {
if (ttsVoicesLoading) return;
ttsVoicesLoading = true;
ttsVoicesError = "";
try {
ttsVoices = await invoke("tts_list_voices");
ttsVoicesLoaded = true;
} catch (err) {
ttsVoicesError = String(err);
} finally {
ttsVoicesLoading = false;
}
}
async function toggleReadAloudSection() {
openSection = openSection === 'readAloud' ? null : 'readAloud';
if (openSection === 'readAloud' && !ttsVoicesLoaded) {
await refreshTtsVoices();
}
}
async function testReadAloudVoice() {
try {
await invoke("tts_speak", {
text: "This is Corbie reading aloud.",
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch (err) {
toasts.warn("Could not read aloud", String(err));
}
}
onMount(async () => {
try {
await refreshRuntimeCapabilities();
@@ -1355,6 +1396,77 @@
{/if}
</div>
<!-- Read aloud (Phase 4 roadmap) -->
<div class="border-b border-border-subtle">
<button
class="flex items-center justify-between w-full py-4 px-5 text-left"
onclick={toggleReadAloudSection}
>
<h3 class="font-display text-[18px] italic text-text">Read aloud</h3>
<span class="text-text-tertiary text-[16px] leading-none">{openSection === 'readAloud' ? '' : '+'}</span>
</button>
{#if openSection === 'readAloud'}
<div class="px-5 pb-5 animate-fade-in">
<p class="text-[11px] text-text-tertiary mb-4">
Uses your operating system's built-in voices. No audio leaves the machine.
</p>
<div class="mb-4">
<label for="tts-voice" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">Voice</label>
<select
id="tts-voice"
class="w-full bg-bg-input border border-border rounded-lg px-3 py-2 text-[12px] text-text focus:border-accent focus:outline-none"
value={settings.ttsVoice ?? ""}
onchange={(e) => { settings.ttsVoice = e.currentTarget.value || null; }}
disabled={ttsVoicesLoading}
>
<option value="">System default</option>
{#each ttsVoices as voice (voice.id)}
<option value={voice.id}>
{voice.name}{#if voice.language} · {voice.language}{/if}
</option>
{/each}
</select>
{#if ttsVoicesError}
<p class="text-[11px] text-danger mt-2">{ttsVoicesError}</p>
{:else if ttsVoicesLoaded && ttsVoices.length === 0}
<p class="text-[11px] text-text-tertiary mt-2">
No additional voices reported by the system synth. Install extra voices through your OS accessibility settings.
</p>
{/if}
</div>
<div class="mb-4">
<label for="tts-rate" class="block text-[10px] font-medium text-text-tertiary uppercase tracking-wider mb-2">
Rate · {settings.ttsRate.toFixed(1)}×
</label>
<input
id="tts-rate"
type="range"
min="0.5"
max="2.0"
step="0.1"
class="w-full accent-accent"
bind:value={settings.ttsRate}
/>
<div class="flex justify-between text-[10px] text-text-tertiary mt-1">
<span>Slower</span>
<span>Normal</span>
<span>Faster</span>
</div>
</div>
<button
type="button"
class="px-3 py-2 rounded-lg bg-bg-elevated border border-border text-[12px] text-text hover:border-accent"
onclick={testReadAloudVoice}
>
Test voice
</button>
</div>
{/if}
</div>
<!-- AI Assistant -->
<div class="border-b border-border-subtle">
<button