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

@@ -2,6 +2,7 @@
import { invoke } from '@tauri-apps/api/core';
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
@@ -270,6 +271,12 @@
>
<Pencil size={10} aria-hidden="true" />
</button>
<span
class="opacity-0 group-hover:opacity-100"
style={reduceMotion ? '' : 'transition: opacity var(--duration-ui)'}
>
<SpeakerButton text={step.text} label="Read this step aloud" size={10} />
</span>
<button
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
onclick={() => startTimer(step.id)}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { Volume2, Square } from 'lucide-svelte';
import { settings } from '$lib/stores/page.svelte.js';
import { activeSpeaker, setActiveSpeaker } from '$lib/stores/speaker.svelte.ts';
let {
text,
label = 'Read aloud',
size = 12,
}: { text: string; label?: string; size?: number } = $props();
// A stable id per mounted button. `crypto.randomUUID` is available
// in any runtime recent enough to host Tauri's webview.
const instanceId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `sp_${Math.random().toString(36).slice(2)}`;
let revertTimer: ReturnType<typeof setTimeout> | null = null;
const speaking = $derived(activeSpeaker.id === instanceId);
async function toggle() {
if (speaking) {
await stop();
} else {
await start();
}
}
async function start() {
// If a different button is active, cancel its speech first so we
// don't get two synths talking over each other.
if (activeSpeaker.id !== null && activeSpeaker.id !== instanceId) {
try {
await invoke('tts_stop');
} catch {
// Best-effort: nothing to gain from surfacing a stop error.
}
}
setActiveSpeaker(instanceId);
try {
await invoke('tts_speak', {
text,
rate: settings.ttsRate,
voice: settings.ttsVoice ?? null,
});
} catch {
setActiveSpeaker(null);
return;
}
scheduleRevert();
}
async function stop() {
clearRevert();
setActiveSpeaker(null);
try {
await invoke('tts_stop');
} catch {
// Best-effort.
}
}
// No platform exposes a "speech finished" signal cheaply, so we
// estimate duration from word count and revert the icon when it
// elapses. 150 wpm is a comfortable pace for British-English; the
// user's rate slider shortens or lengthens the estimate.
function scheduleRevert() {
clearRevert();
const words = text.trim().split(/\s+/).filter(Boolean).length;
const baseSeconds = Math.max(2, Math.min(600, (words / 150) * 60));
const rate = settings.ttsRate > 0 ? settings.ttsRate : 1.0;
const ms = Math.round((baseSeconds * 1000) / rate);
revertTimer = setTimeout(() => {
if (activeSpeaker.id === instanceId) {
setActiveSpeaker(null);
}
}, ms);
}
function clearRevert() {
if (revertTimer !== null) {
clearTimeout(revertTimer);
revertTimer = null;
}
}
onDestroy(() => {
clearRevert();
if (activeSpeaker.id === instanceId) {
setActiveSpeaker(null);
invoke('tts_stop').catch(() => {});
}
});
</script>
<button
type="button"
class="p-0.5 text-text-tertiary hover:text-accent {speaking ? '!text-accent' : ''}"
onclick={toggle}
aria-label={speaking ? 'Stop reading aloud' : label}
title={speaking ? 'Stop reading aloud' : label}
>
{#if speaking}
<Square {size} aria-hidden="true" />
{:else}
<Volume2 {size} aria-hidden="true" />
{/if}
</button>