Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export default defineConfig({
],
use: {
baseURL: "http://127.0.0.1:4173",
launchOptions: process.env.PLAYWRIGHT_CHROME_EXECUTABLE
? { executablePath: process.env.PLAYWRIGHT_CHROME_EXECUTABLE }
: undefined,
screenshot: "only-on-failure",
trace: "on-first-retry",
video: "retain-on-failure",
Expand All @@ -35,6 +38,7 @@ export default defineConfig({
"**/invites-settings-screenshots.spec.ts",
"**/messaging.spec.ts",
"**/message-feedback-snapshots.spec.ts",
"**/message-read-aloud-screenshots.spec.ts",
"**/custom-emoji.spec.ts",
"**/profile-custom-emoji-status.spec.ts",
"**/custom-emoji-ui.spec.ts",
Expand Down
134 changes: 134 additions & 0 deletions desktop/src-tauri/src/huddle/message_read_aloud.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! On-demand read-aloud of a single chat message through the local Pocket
//! TTS engine — the same pipeline (and selected Settings voice) the huddle
//! uses for agent speech.
//!
//! Mirrors the `preview_pocket_voice` structure: a short-lived pipeline per
//! request, completion detected by polling the pipeline's active flag. Only
//! one message plays at a time; starting a new one cancels the previous.

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};

use tauri::{AppHandle, Emitter, State};

use crate::app_state::AppState;

use super::models;
use super::tts::TtsPipeline;
use super::tts_settings::{current_settings, pocket_voice_reference};

/// Emitted (payload: the caller's `session_id`) when audio playback actually
/// starts, so the UI can move from "preparing" to "playing".
const STARTED_EVENT: &str = "message-read-aloud-started";

/// How long synthesis may run before the first audible sample. Matches the
/// voice-preview timeout.
const PLAYBACK_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

/// Poll interval for the playback progress flags.
const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);

/// Cancel flag for the in-flight message read-aloud playback, if any.
/// Starting a new read-aloud cancels the previous one; stop raises it.
static ACTIVE_CANCEL: Mutex<Option<Arc<AtomicBool>>> = Mutex::new(None);

/// Swap the shared cancel slot, returning the previous occupant.
fn swap_active_cancel(next: Option<Arc<AtomicBool>>) -> Option<Arc<AtomicBool>> {
let mut slot = ACTIVE_CANCEL
.lock()
.unwrap_or_else(|error| error.into_inner());
std::mem::replace(&mut *slot, next)
}

/// Speak `text` through the local Pocket TTS engine with the user's selected
/// Settings voice.
///
/// Resolves `Ok(true)` once playback finishes, `Ok(false)` when cancelled by
/// [`stop_message_read_aloud`] or a newer read-aloud request. Errors when the
/// voice files are not ready, no Pocket voice is available, or audio never
/// starts.
#[tauri::command]
pub async fn speak_message_read_aloud(
session_id: String,
text: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<bool, String> {
if text.trim().is_empty() {
return Err("Nothing to read aloud".to_string());
}
if !models::is_tts_ready() {
return Err("Voice files are still downloading. Try again shortly.".to_string());
}
let model_dir = models::tts_model_dir().ok_or("Pocket voice files are unavailable")?;
let settings = current_settings(&state)?;
let voice_name = pocket_voice_reference(&app, &settings.voice_preferences)?;
let output_device = state
.huddle_audio
.output_device
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone();

// Only one message plays at a time — cancel whichever was still running.
let cancel = Arc::new(AtomicBool::new(false));
if let Some(previous) = swap_active_cancel(Some(Arc::clone(&cancel))) {
previous.store(true, Ordering::Release);
}
let cancel_worker = Arc::clone(&cancel);

let result = tokio::task::spawn_blocking(move || {
let active = Arc::new(AtomicBool::new(false));
let pipeline = TtsPipeline::new_with_voice(
model_dir,
Arc::clone(&active),
Arc::clone(&cancel_worker),
&voice_name,
output_device,
)?;
pipeline.speak(text)?;
let started = std::time::Instant::now();
let mut heard_audio = false;
loop {
if cancel_worker.load(Ordering::Acquire) {
return Ok(false);
}
let is_active = active.load(Ordering::Acquire);
if is_active && !heard_audio {
heard_audio = true;
let _ = app.emit(STARTED_EVENT, &session_id);
}
if heard_audio && !is_active {
return Ok(true);
}
if !heard_audio && started.elapsed() > PLAYBACK_START_TIMEOUT {
return Err(
"Playback did not start. Check your audio output and try again.".to_string(),
);
}
std::thread::sleep(POLL_INTERVAL);
}
})
.await
.map_err(|error| format!("Read-aloud task failed: {error}"))?;

// Release the slot if it still belongs to this request so a stale Arc
// doesn't linger after playback ends.
{
let mut slot = ACTIVE_CANCEL
.lock()
.unwrap_or_else(|error| error.into_inner());
if slot.as_ref().is_some_and(|held| Arc::ptr_eq(held, &cancel)) {
*slot = None;
}
}
result
}

/// Stop the in-flight message read-aloud, if any.
#[tauri::command]
pub fn stop_message_read_aloud() {
if let Some(cancel) = swap_active_cancel(None) {
cancel.store(true, Ordering::Release);
}
}
1 change: 1 addition & 0 deletions desktop/src-tauri/src/huddle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ mod agent_tts_routing;
pub mod agents;
pub mod audio_output;
pub mod jitter;
pub mod message_read_aloud;
pub mod models;
pub mod pipeline;
pub mod playout;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/huddle/tts_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ async fn apply_tts_settings(
Ok(voice_change_wait)
}

fn current_settings(state: &AppState) -> Result<TtsSettings, String> {
pub(crate) fn current_settings(state: &AppState) -> Result<TtsSettings, String> {
state
.huddle_audio
.tts
Expand Down
72 changes: 72 additions & 0 deletions desktop/src-tauri/src/initial_window.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//! First-reveal choreography for the main window: keep it hidden (with an
//! opaque backing on macOS) until the initial render is ready and the
//! window-state plugin has settled restored geometry, then show and focus.

#[cfg(target_os = "macos")]
pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";

pub(crate) fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>) {
if let Err(error) = window.show() {
eprintln!("buzz-desktop: failed to reveal main window: {error}");
return;
}
if let Err(error) = window.set_focus() {
eprintln!("buzz-desktop: failed to focus main window: {error}");
}
}

#[cfg(target_os = "macos")]
pub(crate) fn set_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
// The window remains transparent at runtime for vibrancy. Use an opaque
// native backing only across the first visible frames so the previous app
// cannot show through before WebKit has submitted its first surface.
if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) {
eprintln!("buzz-desktop: failed to set initial window backing: {error}");
}
}

#[cfg(target_os = "macos")]
pub(crate) async fn clear_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if let Err(error) = window.set_background_color(None) {
eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
}
}

#[cfg(target_os = "macos")]
pub(crate) async fn wait_for_stable_initial_window_geometry<R: tauri::Runtime>(
window: &tauri::Window<R>,
) {
const MAX_POLLS: usize = 120;
const REQUIRED_STABLE_POLLS: usize = 4;

let mut previous_bounds = None;
let mut stable_polls = 0;

for _ in 0..MAX_POLLS {
// Accept whatever geometry the window-state plugin restores — maximized
// or a normal saved size. macOS applies the restore asynchronously, so
// we only need consecutive identical outer bounds to know it settled.
// Gating on `is_maximized()` here would leave `bounds` permanently
// `None` for restored non-maximized windows and stall the reveal until
// the poll timeout.
let bounds = match (window.outer_position(), window.outer_size()) {
(Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)),
_ => None,
};

if bounds.is_some() && bounds == previous_bounds {
stable_polls += 1;
if stable_polls >= REQUIRED_STABLE_POLLS {
return;
}
} else {
stable_polls = 0;
}
previous_bounds = bounds;

tokio::time::sleep(std::time::Duration::from_millis(16)).await;
}

eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout");
}
71 changes: 4 additions & 67 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod event_sync;
mod events;
mod huddle;
mod identity_storage;
mod initial_window;
mod key_backup;
mod linux_media;
mod managed_agents;
Expand Down Expand Up @@ -54,6 +55,7 @@ use huddle::{
join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled,
set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline,
};
use initial_window::*;
use managed_agents::{
backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes,
put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes,
Expand All @@ -73,73 +75,6 @@ use tauri_plugin_window_state::StateFlags;
#[cfg(target_os = "macos")]
use tray_menu::show_main_window;

#[cfg(target_os = "macos")]
const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready";

fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>) {
if let Err(error) = window.show() {
eprintln!("buzz-desktop: failed to reveal main window: {error}");
return;
}
if let Err(error) = window.set_focus() {
eprintln!("buzz-desktop: failed to focus main window: {error}");
}
}

#[cfg(target_os = "macos")]
fn set_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
// The window remains transparent at runtime for vibrancy. Use an opaque
// native backing only across the first visible frames so the previous app
// cannot show through before WebKit has submitted its first surface.
if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) {
eprintln!("buzz-desktop: failed to set initial window backing: {error}");
}
}

#[cfg(target_os = "macos")]
async fn clear_initial_window_backing<R: tauri::Runtime>(window: &tauri::Window<R>) {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if let Err(error) = window.set_background_color(None) {
eprintln!("buzz-desktop: failed to clear initial window backing: {error}");
}
}

#[cfg(target_os = "macos")]
async fn wait_for_stable_initial_window_geometry<R: tauri::Runtime>(window: &tauri::Window<R>) {
const MAX_POLLS: usize = 120;
const REQUIRED_STABLE_POLLS: usize = 4;

let mut previous_bounds = None;
let mut stable_polls = 0;

for _ in 0..MAX_POLLS {
// Accept whatever geometry the window-state plugin restores — maximized
// or a normal saved size. macOS applies the restore asynchronously, so
// we only need consecutive identical outer bounds to know it settled.
// Gating on `is_maximized()` here would leave `bounds` permanently
// `None` for restored non-maximized windows and stall the reveal until
// the poll timeout.
let bounds = match (window.outer_position(), window.outer_size()) {
(Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)),
_ => None,
};

if bounds.is_some() && bounds == previous_bounds {
stable_polls += 1;
if stable_polls >= REQUIRED_STABLE_POLLS {
return;
}
} else {
stable_polls = 0;
}
previous_bounds = bounds;

tokio::time::sleep(std::time::Duration::from_millis(16)).await;
}

eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout");
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// mesh-llm's async chains (model download, node start/join) overflow
Expand Down Expand Up @@ -898,6 +833,8 @@ pub fn run() {
huddle::tts_settings::preview_pocket_voice,
huddle::tts_settings::import_pocket_voice,
huddle::tts_settings::delete_pocket_voice,
huddle::message_read_aloud::speak_message_read_aloud,
huddle::message_read_aloud::stop_message_read_aloud,
speak_agent_message,
add_agent_to_huddle,
check_pipeline_hotstart,
Expand Down
Loading
Loading