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

@@ -3,6 +3,7 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
import { formatTime, formatDuration } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { errorMessage } from "$lib/utils/errors.js";
@@ -374,13 +375,18 @@
{#if item}
<!-- Item info -->
<div class="px-5 pt-3 pb-2">
<p class="text-[13px] text-text font-medium">
{item.title || "Transcript"}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
</p>
<div class="px-5 pt-3 pb-2 flex items-start gap-2">
<div class="flex-1 min-w-0">
<p class="text-[13px] text-text font-medium">
{item.title || "Transcript"}
</p>
<p class="text-[10px] text-text-tertiary mt-0.5">
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
</p>
</div>
{#if item.text?.trim()}
<SpeakerButton text={item.text} label="Read transcript aloud" size={14} />
{/if}
</div>
<!-- Media controls -->