Files
Lumotia/src-tauri/src/commands/tts.rs
Claude 4abc2356c2 build(android): cfg-gate desktop-only Tauri surfaces for android target
Phase 1 of the Android same-repo target plan: make the workspace
compilable for `aarch64-linux-android` (and the other NDK ABIs) by
removing the desktop-only crate dependencies and command bodies from the
Android build. After this commit, `tauri android init` followed by
`cargo tauri android build` is structurally unblocked — the remaining
work is the SDK/NDK toolchain (off-sandbox), the Svelte single-window
refactor, and the Phase 3 MVP feature surface.

What's gated under `cfg(not(target_os = "android"))`:

- src-tauri/Cargo.toml: `tauri = { features = ["tray-icon"] }` is now
  declared in the desktop-only target block. The `global-shortcut`,
  `window-state`, and `autostart` plugins join it — none of the three
  support Android natively. The base `tauri = "2"` plus `dialog`,
  `opener`, and `notification` plugins remain unconditional because they
  do support Android.
- src-tauri/src/lib.rs: `mod tray` declaration, the matching
  `tray::setup(app)` call, the close-to-tray `WindowEvent::CloseRequested`
  handler, and the `.plugin(tauri_plugin_global_shortcut::*)` /
  `_autostart` / `_window_state` chain are all desktop-only. The
  builder is split with a single `#[cfg(not(target_os = "android"))]`
  branch that adds the desktop plugins on top of the universal base.
- src-tauri/src/commands/tts.rs: `tts_speak` previously had three
  `#[cfg(target_os = ...)]` branches but no fallback, so on Android the
  `spawned` binding was unbound and the function failed to compile.
  Mirrored the existing `paste.rs` not-implemented fallback. Same fix
  for `list_voices_impl`. Frontend will hide the Read Page Aloud button
  on Android via `isAndroid()`.
- src-tauri/src/commands/windows.rs: all four multi-window commands
  (`open_task_window`, `open_preview_window`, `close_preview_window`,
  `open_viewer_window`) get an Android stub that returns a clear
  "Multi-window is not supported on Android" error. Tauri on Android
  is single-Activity; the previously-secondary content (preview overlay,
  transcript viewer, task float) will live as routes inside the main
  window, gated by `isAndroid()` on the frontend.

What's *not* changed:
- Top-level `identifier` in tauri.conf.json stays `uk.co.corbel.kon`.
  The Phase 10b Kon → Corbie rename sweep will land
  `corbel.technology.corbie` as part of a coherent rebrand commit
  rather than fragmenting the rename across this branch.
- `bundle.android.minSdkVersion: 24` added so a future
  `tauri android init` knows to target Android 7.0+ (Vulkan available,
  scoped storage starts at 29 — we'll surface scoped-storage paths
  via Tauri's dialog plugin on Phase 3).
- `kon-hotkey` already exports a non-Linux stub; no changes needed.
- `commands/meeting.rs` still calls `process_watch::list_running_process_names()`
  which compiles on Android but returns an empty list (SELinux blocks
  /proc walk on API 24+). Frontend will hide the toggle on Android.

Verification: 91/91 tests still pass on the buildable-in-sandbox crates
(kon-storage 60, kon-core 16, kon-mcp 9, kon-hotkey 4, kon-cloud-providers
2). svelte-check 0/0 across 3957 files. The src-tauri crate itself can't
be compiled in this sandbox (no webkit2gtk); CI's desktop builders will
exercise the desktop branch, and Jake's Android-equipped dev box will
exercise the Android branch via `tauri android init`.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
2026-04-25 12:50:33 +00:00

445 lines
14 KiB
Rust

//! 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())
}
// Non-desktop fallback. Android's native TextToSpeech API would need a
// JNI bridge — out of scope for v0.1-android. The frontend already hides
// the Read Page Aloud button behind a runtime detection; this stub keeps
// the command surface consistent so a stray invoke surfaces a clear
// error rather than panicking on an unbound `list_voices_impl`.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn list_voices_impl() -> Result<Vec<TtsVoice>, String> {
Ok(Vec::new())
}
// ---------- 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())?;
// No bundled TTS shim for non-desktop targets. Android has its own
// TextToSpeech APIs that would have to be reached over JNI; out of
// scope for v0.1-android. Mirrors the paste.rs not-implemented
// fallback so the command compiles cleanly across all targets and
// surfaces a clear error if the frontend tries to invoke it.
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = (state, rate, voice);
return Err("TTS not implemented on this platform".into());
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
{
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"));
}
}