feat(tts): Phase 4 — Read Page Aloud with OS-native voices
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:
@@ -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)}
|
||||
|
||||
112
src/lib/components/SpeakerButton.svelte
Normal file
112
src/lib/components/SpeakerButton.svelte
Normal 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>
|
||||
@@ -17,6 +17,7 @@
|
||||
import { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||
import { bionicReading } from '$lib/actions/bionicReading.js';
|
||||
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
||||
@@ -1069,6 +1070,9 @@
|
||||
<span class="text-[11px] text-text-tertiary">
|
||||
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
||||
</span>
|
||||
{#if transcript.trim()}
|
||||
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,6 +71,8 @@ const defaults: SettingsState = {
|
||||
microphoneDevice: "",
|
||||
currentEnergy: null,
|
||||
matchMyEnergy: false,
|
||||
ttsVoice: null,
|
||||
ttsRate: 1.0,
|
||||
};
|
||||
|
||||
function canUseStorage(): boolean {
|
||||
|
||||
10
src/lib/stores/speaker.svelte.ts
Normal file
10
src/lib/stores/speaker.svelte.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Phase 4 Read Page Aloud: tracks which SpeakerButton instance is
|
||||
// currently driving TTS. Only one speaker is active at a time — when
|
||||
// a second button starts speech, the first reverts its icon via the
|
||||
// `$derived` subscription in SpeakerButton.
|
||||
|
||||
export const activeSpeaker = $state<{ id: string | null }>({ id: null });
|
||||
|
||||
export function setActiveSpeaker(id: string | null): void {
|
||||
activeSpeaker.id = id;
|
||||
}
|
||||
@@ -70,6 +70,13 @@ export interface SettingsState {
|
||||
* the user explicitly opts in.
|
||||
*/
|
||||
matchMyEnergy: boolean;
|
||||
/**
|
||||
* Phase 4 Read Page Aloud: OS-native TTS. `voice` is the platform's
|
||||
* voice id (e.g. macOS `Alex`), or `null` for system default.
|
||||
* `rate` is 0.5..2.0 where 1.0 is the synth's normal speed.
|
||||
*/
|
||||
ttsVoice: string | null;
|
||||
ttsRate: number;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
||||
Reference in New Issue
Block a user