Skip to content
Draft
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export default defineConfig({
"**/activity-scope-label-screenshots.spec.ts",
"**/welcome-agent-modal-screenshots.spec.ts",
"**/local-archive-screenshots.spec.ts",
"**/voice-settings.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/agent-error-state-screenshots.spec.ts",
"**/edit-agent.spec.ts",
Expand Down
35 changes: 35 additions & 0 deletions desktop/src-tauri/resources/pocket-voices/NOTICE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Pocket TTS English VCTK presets

Buzz exposes Kyutai's twelve official English VCTK Pocket presets. The WAV
bytes are unchanged from `kyutai/tts-voices` revision
`323332d33f997de8394f24a193e1a76df720e01a`; only local filenames differ.

| Voice | Upstream asset | SHA-256 |
| --- | --- | --- |
| Anna | `vctk/p228_023_enhanced.wav` | `0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856` |
| Vera | `vctk/p229_023_enhanced.wav` | `309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b` |
| Fantine | `vctk/p244_023_enhanced.wav` | `5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b` |
| Charles | `vctk/p254_023_enhanced.wav` | `6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756` |
| Paul | `vctk/p259_023_enhanced.wav` | `7aba504fe0b3b16478b69eb27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b` |
| Eponine | `vctk/p262_023_enhanced.wav` | `a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b` |
| Azelma | `vctk/p303_023_enhanced.wav` | `60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026` |
| George | `vctk/p315_023_enhanced.wav` | `29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae` |
| Mary | `vctk/p333_023_enhanced.wav` | `a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f` |
| Jane | `vctk/p339_023_enhanced.wav` | `2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a` |
| Michael | `vctk/p360_023_enhanced.wav` | `b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad` |
| Eve | `vctk/p361_023_enhanced.wav` | `396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd` |

Mary is already installed as the Pocket model's `reference_sample.wav`, so it
is not duplicated in this resource directory.

Source repository:
https://huggingface.co/kyutai/tts-voices/tree/323332d33f997de8394f24a193e1a76df720e01a/vctk

The original recordings are from the Voice Cloning Toolkit (VCTK) corpus,
licensed CC BY 4.0:
https://datashare.ed.ac.uk/handle/10283/3443

The recordings were enhanced by ai-coustics:
https://ai-coustics.com/

Neither Kyutai, the VCTK speakers, nor ai-coustics endorses Buzz.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
6 changes: 2 additions & 4 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,13 @@ pub struct AppState {
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<ManagedAgentRuntimeKey, ManagedAgentPairRuntime>>,
pub huddle_state: Mutex<HuddleState>,
pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState,
/// Tauri app handle — stored after setup so huddle commands can emit
/// `huddle-state-changed` events without needing the handle threaded
/// through every call site.
///
/// Set once during `setup()` in `lib.rs`; never cleared.
pub app_handle: Mutex<Option<AppHandle>>,
/// Selected audio output device name. `None` = system default.
/// Used by `connect_audio_relay` and TTS pipeline when opening sinks.
pub audio_output_device: Mutex<Option<String>>,
/// Port of the localhost media streaming proxy (set during setup).
pub media_proxy_port: AtomicU16,
/// Set when identity resolution detected a "keyring-locked" state: the
Expand Down Expand Up @@ -213,8 +211,8 @@ pub fn build_app_state() -> AppState {
managed_agent_processes: Mutex::new(HashMap::new()),
session_config_cache: Mutex::new(HashMap::new()),
huddle_state: Mutex::new(HuddleState::default()),
huddle_audio: Default::default(),
app_handle: Mutex::new(None),
audio_output_device: Mutex::new(None),
media_proxy_port: AtomicU16::new(0),
prevent_sleep: Arc::new(Mutex::new(
crate::prevent_sleep::PreventSleepState::default(),
Expand Down
56 changes: 56 additions & 0 deletions desktop/src-tauri/src/huddle/agent_tts_routing.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
use super::HuddlePhase;

#[derive(Debug, PartialEq, Eq)]
pub(super) enum AgentTtsRuntimeGate {
Disabled,
Inactive,
NeedsPipeline,
Ready,
}

pub(super) fn classify_agent_tts_runtime(
enabled: bool,
phase: &HuddlePhase,
has_pipeline: bool,
) -> AgentTtsRuntimeGate {
if !enabled {
AgentTtsRuntimeGate::Disabled
} else if !matches!(phase, HuddlePhase::Connected | HuddlePhase::Active) {
AgentTtsRuntimeGate::Inactive
} else if has_pipeline {
AgentTtsRuntimeGate::Ready
} else {
AgentTtsRuntimeGate::NeedsPipeline
}
}

/// Maximum text length accepted for TTS synthesis.
/// ~2000 chars is 1–2 minutes of speech. Longer messages are truncated.
pub(super) const MAX_TTS_TEXT_LEN: usize = 2000;

pub(super) fn normalize_agent_tts_text(text: String) -> String {
if text.chars().count() > MAX_TTS_TEXT_LEN {
let mut truncated: String = text.chars().take(MAX_TTS_TEXT_LEN).collect();
truncated.push_str("... message truncated.");
truncated
} else {
text
}
}

pub(super) async fn enqueue_agent_tts_text<F>(
route_id: u64,
text: String,
enqueue: F,
) -> Result<(), String>
where
F: FnOnce(u64, String) -> Result<(), String> + Send + 'static,
{
tokio::task::spawn_blocking(move || enqueue(route_id, text))
.await
.map_err(|error| format!("TTS enqueue task failed: {error}"))?
}

#[cfg(test)]
#[path = "agent_tts_routing_tests.rs"]
mod tests;
57 changes: 57 additions & 0 deletions desktop/src-tauri/src/huddle/agent_tts_routing_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use super::{
classify_agent_tts_runtime, enqueue_agent_tts_text, normalize_agent_tts_text,
AgentTtsRuntimeGate, MAX_TTS_TEXT_LEN,
};
use crate::huddle::HuddlePhase;

#[tokio::test]
async fn assistant_plain_text_routes_unchanged_into_voice_pipeline_boundary() {
let (sender, receiver) = std::sync::mpsc::channel();
let text = "A newly submitted assistant reply.".to_string();
let route_id = 42;

enqueue_agent_tts_text(route_id, text.clone(), move |route_id, queued| {
sender
.send((route_id, queued))
.map_err(|error| error.to_string())
})
.await
.expect("route assistant text");

assert_eq!(
receiver.recv().expect("queued text"),
(route_id, text),
"route correlation must survive the native queue boundary"
);
}

#[test]
fn disabled_is_the_only_intentional_runtime_no_op() {
assert_eq!(
classify_agent_tts_runtime(false, &HuddlePhase::Connected, false),
AgentTtsRuntimeGate::Disabled
);
assert_eq!(
classify_agent_tts_runtime(true, &HuddlePhase::Idle, false),
AgentTtsRuntimeGate::Inactive
);
assert_eq!(
classify_agent_tts_runtime(true, &HuddlePhase::Connected, false),
AgentTtsRuntimeGate::NeedsPipeline
);
assert_eq!(
classify_agent_tts_runtime(true, &HuddlePhase::Connected, true),
AgentTtsRuntimeGate::Ready
);
}

#[test]
fn assistant_text_truncation_is_unicode_safe_before_voice_routing() {
let input = "🦀".repeat(MAX_TTS_TEXT_LEN + 1);
let output = normalize_agent_tts_text(input);
assert_eq!(
output.chars().count(),
MAX_TTS_TEXT_LEN + "... message truncated.".chars().count()
);
assert!(output.ends_with("... message truncated."));
}
6 changes: 4 additions & 2 deletions desktop/src-tauri/src/huddle/audio_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ fn list_audio_output_devices_blocking() -> Result<Vec<AudioOutputDevice>, String
#[tauri::command]
pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Result<(), String> {
let mut guard = state
.audio_output_device
.huddle_audio
.output_device
.lock()
.map_err(|e| e.to_string())?;
*guard = if name.is_empty() { None } else { Some(name) };
Expand All @@ -50,7 +51,8 @@ pub fn set_audio_output_device(name: String, state: State<'_, AppState>) -> Resu
#[tauri::command]
pub fn get_audio_output_device(state: State<'_, AppState>) -> Result<String, String> {
let guard = state
.audio_output_device
.huddle_audio
.output_device
.lock()
.map_err(|e| e.to_string())?;
Ok(guard.clone().unwrap_or_default())
Expand Down
Loading
Loading