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:
@@ -78,3 +78,8 @@ gdk = "0.18"
|
|||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
objc2 = "0.6.4"
|
objc2 = "0.6.4"
|
||||||
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
|
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSString", "NSProcessInfo"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
# Phase 4 TTS: PowerShell -EncodedCommand expects UTF-16-LE base64.
|
||||||
|
# Windows-only because the other platforms' TTS paths pass text via argv.
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub mod profiles;
|
|||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod transcripts;
|
pub mod transcripts;
|
||||||
|
pub mod tts;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
pub mod windows;
|
pub mod windows;
|
||||||
|
|
||||||
|
|||||||
421
src-tauri/src/commands/tts.rs
Normal file
421
src-tauri/src/commands/tts.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
//! Phase 4 of the feature-complete roadmap: platform-native Read Page Aloud.
|
||||||
|
//!
|
||||||
|
//! Layer-1 scope: shell out to the OS's built-in TTS binary, return
|
||||||
|
//! immediately (non-blocking), track the spawned child so `tts_stop`
|
||||||
|
//! can cancel in-flight speech. No SSML, no pause/resume, no cloud
|
||||||
|
//! voices. User text is never interpolated into a shell string —
|
||||||
|
//! every platform passes the text via argv (or, on Windows, inside a
|
||||||
|
//! PowerShell here-string delivered through `-EncodedCommand`).
|
||||||
|
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Active synth child process, if any. On Linux `spd-say` returns
|
||||||
|
/// immediately so the slot is usually empty; macOS `say` and Windows
|
||||||
|
/// PowerShell speak synchronously, so we store the handle to kill
|
||||||
|
/// on `tts_stop`.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct TtsState {
|
||||||
|
child: Mutex<Option<Child>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TtsState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TtsVoice {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub language: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp user-supplied rate into the app's supported range.
|
||||||
|
/// `0.5` = half speed, `1.0` = normal, `2.0` = double speed.
|
||||||
|
pub fn clamp_rate(rate: f32) -> f32 {
|
||||||
|
if !rate.is_finite() {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
rate.clamp(0.5, 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Linux (spd-say, espeak-ng fallback) ----------
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn spd_rate(rate: f32) -> i32 {
|
||||||
|
// spd-say -r takes -100..=100. 1.0 -> 0 (default rate). We map the
|
||||||
|
// 1.0..=2.0 half linearly to 0..=100, and the 0.5..=1.0 half
|
||||||
|
// linearly to -50..=0. Asymmetric but simple; users who want
|
||||||
|
// slower playback than -50 can change the system synth rate in
|
||||||
|
// their accessibility settings.
|
||||||
|
let r = clamp_rate(rate);
|
||||||
|
((r - 1.0) * 100.0).round().clamp(-100.0, 100.0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn espeak_rate(rate: f32) -> u32 {
|
||||||
|
// espeak-ng -s: words per minute (default 175, min 80, max 450).
|
||||||
|
let r = clamp_rate(rate);
|
||||||
|
((r * 175.0).round() as i32).clamp(80, 450) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn spawn_linux(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||||
|
let mut cmd = Command::new("spd-say");
|
||||||
|
cmd.arg("-r").arg(spd_rate(rate).to_string());
|
||||||
|
if let Some(v) = voice {
|
||||||
|
cmd.arg("-t").arg(v);
|
||||||
|
}
|
||||||
|
cmd.arg("--").arg(text);
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
|
||||||
|
match cmd.spawn() {
|
||||||
|
// spd-say is non-blocking — the child exits before speech
|
||||||
|
// finishes, so there's nothing useful to track.
|
||||||
|
Ok(_) => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
let mut fb = Command::new("espeak-ng");
|
||||||
|
fb.arg("-s").arg(espeak_rate(rate).to_string());
|
||||||
|
if let Some(v) = voice {
|
||||||
|
fb.arg("-v").arg(v);
|
||||||
|
}
|
||||||
|
fb.arg("--").arg(text);
|
||||||
|
fb.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
fb.spawn()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("neither spd-say nor espeak-ng is available: {e}"))
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("failed to spawn spd-say: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn stop_linux() {
|
||||||
|
// Cancels all active spd-say messages for this user. Silent
|
||||||
|
// failure is fine — if spd-say isn't installed there's nothing
|
||||||
|
// to cancel, and any tracked espeak-ng child is killed separately.
|
||||||
|
let _ = Command::new("spd-say")
|
||||||
|
.arg("-S")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- macOS (say) ----------
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn say_rate(rate: f32) -> u32 {
|
||||||
|
// `say -r`: words per minute. 180 wpm is roughly the default; clamp
|
||||||
|
// to a sane range so the slider can't produce an unreadable rate.
|
||||||
|
let r = clamp_rate(rate);
|
||||||
|
((r * 180.0).round() as i32).clamp(90, 500) as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn spawn_macos(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||||
|
let mut cmd = Command::new("say");
|
||||||
|
cmd.arg("-r").arg(say_rate(rate).to_string());
|
||||||
|
if let Some(v) = voice {
|
||||||
|
cmd.arg("-v").arg(v);
|
||||||
|
}
|
||||||
|
cmd.arg("--").arg(text);
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
cmd.spawn()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("failed to spawn `say`: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Windows (PowerShell System.Speech) ----------
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn win_rate(rate: f32) -> i32 {
|
||||||
|
// SpeechSynthesizer.Rate is an integer in -10..=10.
|
||||||
|
let r = clamp_rate(rate);
|
||||||
|
((r - 1.0) * 10.0).round().clamp(-10.0, 10.0) as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A PowerShell single-quoted here-string terminates on `'@` at the
|
||||||
|
/// start of a line. Neutralise any such sequence inside user text so
|
||||||
|
/// the here-string always closes where we intend.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
pub fn escape_ps_herestring(text: &str) -> String {
|
||||||
|
text.replace("'@", "' @")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn spawn_windows(text: &str, rate: f32, voice: Option<&str>) -> Result<Option<Child>, String> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
|
||||||
|
let rate_int = win_rate(rate);
|
||||||
|
let safe_text = escape_ps_herestring(text);
|
||||||
|
let select = match voice {
|
||||||
|
// Regular single-quoted string (not here-string): doubled
|
||||||
|
// single quotes are the escape for a literal `'`.
|
||||||
|
Some(v) => format!("$s.SelectVoice('{}')", v.replace('\'', "''")),
|
||||||
|
None => String::new(),
|
||||||
|
};
|
||||||
|
let script = format!(
|
||||||
|
"Add-Type -AssemblyName System.Speech;\n\
|
||||||
|
$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;\n\
|
||||||
|
$s.Rate = {rate_int};\n\
|
||||||
|
{select};\n\
|
||||||
|
$s.Speak(@'\n\
|
||||||
|
{safe_text}\n\
|
||||||
|
'@)"
|
||||||
|
);
|
||||||
|
let utf16: Vec<u8> = script
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(|u| u.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
let encoded = STANDARD.encode(&utf16);
|
||||||
|
|
||||||
|
let mut cmd = Command::new("powershell.exe");
|
||||||
|
cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded]);
|
||||||
|
cmd.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null());
|
||||||
|
cmd.spawn()
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("failed to spawn powershell: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Voice listing ----------
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||||
|
// spd-say's voice set depends on the active synth module and the
|
||||||
|
// list format isn't stable across distributions. For Phase 4 the
|
||||||
|
// picker renders "System default" only, which matches what
|
||||||
|
// `tts_speak` without a voice argument does anyway.
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||||
|
let out = Command::new("say")
|
||||||
|
.arg("-v")
|
||||||
|
.arg("?")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to query voices: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!("`say -v ?` exited with status {}", out.status));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
Ok(parse_macos_voices(&stdout))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn parse_macos_voices(raw: &str) -> Vec<TtsVoice> {
|
||||||
|
raw.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (prefix, _sample) = line.split_once('#')?;
|
||||||
|
let mut parts = prefix.split_whitespace();
|
||||||
|
let name = parts.next()?.to_string();
|
||||||
|
let locale = parts.next().map(str::to_string);
|
||||||
|
Some(TtsVoice {
|
||||||
|
id: name.clone(),
|
||||||
|
name,
|
||||||
|
language: locale,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
|
||||||
|
let script = "Add-Type -AssemblyName System.Speech;\n\
|
||||||
|
(New-Object System.Speech.Synthesis.SpeechSynthesizer).GetInstalledVoices() |\n\
|
||||||
|
ForEach-Object { $_.VoiceInfo } |\n\
|
||||||
|
ForEach-Object { @{ Name = $_.Name; Culture = $_.Culture.Name } } |\n\
|
||||||
|
ConvertTo-Json -Compress";
|
||||||
|
let utf16: Vec<u8> = script
|
||||||
|
.encode_utf16()
|
||||||
|
.flat_map(|u| u.to_le_bytes())
|
||||||
|
.collect();
|
||||||
|
let encoded = STANDARD.encode(&utf16);
|
||||||
|
let out = Command::new("powershell.exe")
|
||||||
|
.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to query voices: {e}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"PowerShell voice-list exited with status {}",
|
||||||
|
out.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||||
|
let trimmed = stdout.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let parsed: serde_json::Value =
|
||||||
|
serde_json::from_str(trimmed).map_err(|e| format!("voice-list JSON parse failed: {e}"))?;
|
||||||
|
// ConvertTo-Json emits a bare object for a single item and an
|
||||||
|
// array otherwise; normalise to always-array.
|
||||||
|
let items: Vec<serde_json::Value> = match parsed {
|
||||||
|
serde_json::Value::Array(a) => a,
|
||||||
|
v => vec![v],
|
||||||
|
};
|
||||||
|
Ok(items
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|v| {
|
||||||
|
let name = v.get("Name")?.as_str()?.to_string();
|
||||||
|
let culture = v
|
||||||
|
.get("Culture")
|
||||||
|
.and_then(|c| c.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
Some(TtsVoice {
|
||||||
|
id: name.clone(),
|
||||||
|
name,
|
||||||
|
language: culture,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tauri commands ----------
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn tts_speak(
|
||||||
|
state: tauri::State<'_, TtsState>,
|
||||||
|
text: String,
|
||||||
|
rate: f32,
|
||||||
|
voice: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
// Cut any in-flight speech so a second tap starts cleanly rather
|
||||||
|
// than queueing on top.
|
||||||
|
kill_child(&state);
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let spawned = spawn_linux(trimmed, rate, voice.as_deref())?;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let spawned = spawn_macos(trimmed, rate, voice.as_deref())?;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let spawned = spawn_windows(trimmed, rate, voice.as_deref())?;
|
||||||
|
|
||||||
|
if let Some(c) = spawned {
|
||||||
|
if let Ok(mut guard) = state.child.lock() {
|
||||||
|
*guard = Some(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn tts_stop(state: tauri::State<'_, TtsState>) -> Result<(), String> {
|
||||||
|
kill_child(&state);
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
stop_linux();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kill_child(state: &TtsState) {
|
||||||
|
if let Ok(mut guard) = state.child.lock() {
|
||||||
|
if let Some(mut c) = guard.take() {
|
||||||
|
let _ = c.kill();
|
||||||
|
let _ = c.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn tts_list_voices() -> Result<Vec<TtsVoice>, String> {
|
||||||
|
list_voices_impl()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tests ----------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_rate_handles_nan() {
|
||||||
|
assert_eq!(clamp_rate(f32::NAN), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_rate_bounds() {
|
||||||
|
assert_eq!(clamp_rate(0.1), 0.5);
|
||||||
|
assert_eq!(clamp_rate(0.5), 0.5);
|
||||||
|
assert_eq!(clamp_rate(1.0), 1.0);
|
||||||
|
assert_eq!(clamp_rate(2.0), 2.0);
|
||||||
|
assert_eq!(clamp_rate(3.0), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn spd_rate_hits_anchors() {
|
||||||
|
assert_eq!(spd_rate(1.0), 0);
|
||||||
|
assert_eq!(spd_rate(2.0), 100);
|
||||||
|
assert_eq!(spd_rate(1.5), 50);
|
||||||
|
// 0.5 is only -50 with the simple linear slope — the asymmetry
|
||||||
|
// is documented in the `spd_rate` doc comment.
|
||||||
|
assert_eq!(spd_rate(0.5), -50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn espeak_rate_stays_in_range() {
|
||||||
|
assert_eq!(espeak_rate(1.0), 175);
|
||||||
|
assert_eq!(espeak_rate(2.0), 350);
|
||||||
|
// 0.5 * 175 = 87.5 → rounds to 88 → still ≥ 80 floor.
|
||||||
|
assert_eq!(espeak_rate(0.5), 88);
|
||||||
|
// NaN → clamp_rate returns 1.0 → rate maps to 175.
|
||||||
|
assert_eq!(espeak_rate(f32::NAN), 175);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn say_rate_maps() {
|
||||||
|
assert_eq!(say_rate(1.0), 180);
|
||||||
|
assert_eq!(say_rate(2.0), 360);
|
||||||
|
assert_eq!(say_rate(0.5), 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn win_rate_bounds() {
|
||||||
|
assert_eq!(win_rate(0.5), -5);
|
||||||
|
assert_eq!(win_rate(1.0), 0);
|
||||||
|
assert_eq!(win_rate(2.0), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[test]
|
||||||
|
fn ps_herestring_terminator_is_broken() {
|
||||||
|
let input = "hello\n'@ evil";
|
||||||
|
let out = escape_ps_herestring(input);
|
||||||
|
assert!(!out.contains("'@"));
|
||||||
|
assert!(out.contains("' @"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
#[test]
|
||||||
|
fn parses_macos_voices() {
|
||||||
|
let raw = "Alex en_US # Most people recognize me\n\
|
||||||
|
Fred en_US # I sure like being inside\n";
|
||||||
|
let voices = parse_macos_voices(raw);
|
||||||
|
assert_eq!(voices.len(), 2);
|
||||||
|
assert_eq!(voices[0].name, "Alex");
|
||||||
|
assert_eq!(voices[0].language.as_deref(), Some("en_US"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -218,6 +218,7 @@ pub fn run() {
|
|||||||
app.manage(commands::hotkey::HotkeyState::new());
|
app.manage(commands::hotkey::HotkeyState::new());
|
||||||
app.manage(commands::audio::NativeCaptureState::new());
|
app.manage(commands::audio::NativeCaptureState::new());
|
||||||
app.manage(commands::live::LiveTranscriptionState::default());
|
app.manage(commands::live::LiveTranscriptionState::default());
|
||||||
|
app.manage(commands::tts::TtsState::new());
|
||||||
|
|
||||||
app.manage(AppState {
|
app.manage(AppState {
|
||||||
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),
|
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),
|
||||||
@@ -292,6 +293,10 @@ pub fn run() {
|
|||||||
// HITL feedback (Phase 2 roadmap)
|
// HITL feedback (Phase 2 roadmap)
|
||||||
commands::feedback::record_feedback,
|
commands::feedback::record_feedback,
|
||||||
commands::feedback::list_feedback_examples_cmd,
|
commands::feedback::list_feedback_examples_cmd,
|
||||||
|
// Read aloud (Phase 4 roadmap)
|
||||||
|
commands::tts::tts_speak,
|
||||||
|
commands::tts::tts_stop,
|
||||||
|
commands::tts::tts_list_voices,
|
||||||
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
// Profiles + profile terms (canonical SQLite-backed profile CRUD) — Task 12
|
||||||
commands::profiles::list_profiles_cmd,
|
commands::profiles::list_profiles_cmd,
|
||||||
commands::profiles::get_profile_cmd,
|
commands::profiles::get_profile_cmd,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
|
import { ListTree, Check, Timer, Loader2, ThumbsUp, ThumbsDown, Pencil } from 'lucide-svelte';
|
||||||
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
|
import { profilesStore } from '$lib/stores/profiles.svelte.ts';
|
||||||
|
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||||
|
|
||||||
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
let { parentTaskId, parentTaskText = '', reduceMotion = false } = $props();
|
||||||
|
|
||||||
@@ -270,6 +271,12 @@
|
|||||||
>
|
>
|
||||||
<Pencil size={10} aria-hidden="true" />
|
<Pencil size={10} aria-hidden="true" />
|
||||||
</button>
|
</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
|
<button
|
||||||
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
|
class="opacity-0 group-hover:opacity-100 flex items-center gap-1 text-[10px] text-text-tertiary hover:text-accent"
|
||||||
onclick={() => startTimer(step.id)}
|
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 { FEEDBACK_TIMEOUT_MS } from "$lib/utils/constants.js";
|
||||||
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
import { Mic, Loader2, SquareCheck, AlertTriangle } from 'lucide-svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import SpeakerButton from '$lib/components/SpeakerButton.svelte';
|
||||||
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
import { getPreferences } from '$lib/stores/preferences.svelte.js';
|
||||||
import { bionicReading } from '$lib/actions/bionicReading.js';
|
import { bionicReading } from '$lib/actions/bionicReading.js';
|
||||||
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
import { measurePreWrap } from '$lib/utils/textMeasure.js';
|
||||||
@@ -1069,6 +1070,9 @@
|
|||||||
<span class="text-[11px] text-text-tertiary">
|
<span class="text-[11px] text-text-tertiary">
|
||||||
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
{settings.formatMode} · {page.activeProfile === "None" ? "No profile" : page.activeProfile}
|
||||||
</span>
|
</span>
|
||||||
|
{#if transcript.trim()}
|
||||||
|
<SpeakerButton text={transcript} label="Read transcript aloud" size={12} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
await refreshRuntimeCapabilities();
|
await refreshRuntimeCapabilities();
|
||||||
@@ -1355,6 +1396,77 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 -->
|
<!-- AI Assistant -->
|
||||||
<div class="border-b border-border-subtle">
|
<div class="border-b border-border-subtle">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ const defaults: SettingsState = {
|
|||||||
microphoneDevice: "",
|
microphoneDevice: "",
|
||||||
currentEnergy: null,
|
currentEnergy: null,
|
||||||
matchMyEnergy: false,
|
matchMyEnergy: false,
|
||||||
|
ttsVoice: null,
|
||||||
|
ttsRate: 1.0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function canUseStorage(): boolean {
|
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.
|
* the user explicitly opts in.
|
||||||
*/
|
*/
|
||||||
matchMyEnergy: boolean;
|
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 {
|
export interface Profile {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||||
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
|
import type { TranscriptEntry, ViewerSegment } from "$lib/types/app";
|
||||||
|
import SpeakerButton from "$lib/components/SpeakerButton.svelte";
|
||||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
import { errorMessage } from "$lib/utils/errors.js";
|
import { errorMessage } from "$lib/utils/errors.js";
|
||||||
@@ -374,13 +375,18 @@
|
|||||||
|
|
||||||
{#if item}
|
{#if item}
|
||||||
<!-- Item info -->
|
<!-- Item info -->
|
||||||
<div class="px-5 pt-3 pb-2">
|
<div class="px-5 pt-3 pb-2 flex items-start gap-2">
|
||||||
<p class="text-[13px] text-text font-medium">
|
<div class="flex-1 min-w-0">
|
||||||
{item.title || "Transcript"}
|
<p class="text-[13px] text-text font-medium">
|
||||||
</p>
|
{item.title || "Transcript"}
|
||||||
<p class="text-[10px] text-text-tertiary mt-0.5">
|
</p>
|
||||||
{item.date}{#if item.duration} · {formatDuration(item.duration)}{/if} · {item.source}
|
<p class="text-[10px] text-text-tertiary mt-0.5">
|
||||||
</p>
|
{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>
|
</div>
|
||||||
|
|
||||||
<!-- Media controls -->
|
<!-- Media controls -->
|
||||||
|
|||||||
Reference in New Issue
Block a user