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: 2 additions & 2 deletions desktop/src-tauri/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result<S
&state,
&[serde_json::json!({
"ids": [event_id],
"kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003],
"kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)
Expand All @@ -228,7 +228,7 @@ async fn resolve_thread_ref(
state,
&[serde_json::json!({
"ids": [parent_event_id],
"kinds": [9, 40002, 45001, 45003],
"kinds": [9, 40002, 45001, 45003, buzz_core_pkg::kind::KIND_HUDDLE_STARTED],
"limit": 1
})],
)
Expand Down
54 changes: 29 additions & 25 deletions desktop/src-tauri/src/huddle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub mod preprocessing;
pub mod relay_api;
pub mod state;
pub mod stt;
pub mod transcription;
pub mod tts;
pub mod wire;

Expand All @@ -60,6 +61,7 @@ pub(super) fn drain_until_shutdown<T>(
// ── Re-exports ────────────────────────────────────────────────────────────────

pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode};
pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline};

// ── Imports ───────────────────────────────────────────────────────────────────

Expand All @@ -75,6 +77,22 @@ use relay_api::{
MAX_HUDDLE_AGENTS,
};

fn normalize_huddle_channel_name(candidate: Option<String>, fallback: &str) -> String {
let normalized = candidate
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");

let name = if normalized.is_empty() {
fallback
} else {
normalized.as_str()
};

name.chars().take(80).collect()
}

// ── Tauri commands ────────────────────────────────────────────────────────────

/// Set the voice input mode (push-to-talk or voice-activity detection).
Expand All @@ -95,6 +113,7 @@ pub async fn set_voice_input_mode(
old_mode != mode
&& matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
&& hs.stt_pipeline.is_some()
&& hs.transcription_enabled
};

if needs_restart {
Expand Down Expand Up @@ -141,6 +160,7 @@ pub fn get_voice_input_mode(state: State<'_, AppState>) -> Result<VoiceInputMode
pub async fn start_huddle(
parent_channel_id: String,
member_pubkeys: Vec<String>,
channel_name: Option<String>,
state: State<'_, AppState>,
) -> Result<HuddleJoinInfo, String> {
// Validate inputs at the Tauri boundary.
Expand Down Expand Up @@ -180,7 +200,8 @@ pub async fn start_huddle(
let ephemeral_uuid = Uuid::new_v4();
let ephemeral_channel_id = ephemeral_uuid.to_string();
let short_id = &ephemeral_channel_id[..8];
let channel_name = format!("huddle-{short_id}");
let fallback_channel_name = format!("huddle-{short_id}");
let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name);

// All steps wrapped so we can roll back on ANY failure, including step 1.
// channel_was_created tracks whether we need to archive on rollback.
Expand Down Expand Up @@ -693,9 +714,13 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S
}
}
// Re-read after potential cleanup.
let (has_stt, has_tts) = {
let (has_stt, has_tts, transcription_enabled) = {
let hs = state.huddle()?;
(hs.stt_pipeline.is_some(), hs.tts_pipeline.is_some())
(
hs.stt_pipeline.is_some(),
hs.tts_pipeline.is_some(),
hs.transcription_enabled,
)
};

// Check if models just became ready (one-shot flags).
Expand All @@ -713,7 +738,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S
}
}

if !has_stt && (stt_ready || models::is_stt_ready()) {
if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) {
if let Some(eph_id) = &ephemeral_channel_id {
if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await {
eprintln!("buzz-desktop: STT hotstart failed: {e}");
Expand Down Expand Up @@ -771,27 +796,6 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S
Ok(())
}

/// Start the STT pipeline for the active huddle.
///
/// Delegates to `maybe_start_stt_pipeline` — returns `Err` if models are not
/// ready or no huddle is active. Safe to call multiple times: replaces the
/// existing pipeline if already running.
#[tauri::command]
pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> {
let ephemeral_channel_id = {
let hs = state.huddle()?;
hs.ephemeral_channel_id
.clone()
.ok_or("no active huddle — start or join a huddle first")?
};

match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await {
Ok(true) => Ok(()),
Ok(false) => Err("STT model not ready".to_string()),
Err(e) => Err(e),
}
}

/// Trigger a background download of voice models (Parakeet STT + Pocket TTS).
///
/// Returns immediately — downloads run in tokio background tasks.
Expand Down
21 changes: 14 additions & 7 deletions desktop/src-tauri/src/huddle/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ pub(crate) async fn post_connect_setup(
}
}

// Ensure voice models are downloading (idempotent).
// Prepare TTS for agent voice. STT is transcript-specific and starts only
// when transcription is explicitly enabled.
if let Some(mgr) = models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
mgr.start_tts_download(state.http_client.clone());
}

Expand All @@ -58,13 +58,11 @@ pub(crate) async fn post_connect_setup(
hs.audio_relay_pcm_tx = Some(pcm_tx);
}

// Start pipelines: TTS first (so STT can capture tts_cancel for barge-in).
// Start TTS immediately. STT/transcript posting is opt-in and starts only
// after the user explicitly enables transcription.
if let Err(e) = maybe_start_tts_pipeline(state).await {
eprintln!("buzz-desktop: TTS pipeline failed to start: {e}");
}
if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await {
eprintln!("buzz-desktop: STT pipeline failed to start: {e}");
}

Ok(())
}
Expand All @@ -81,6 +79,13 @@ pub(crate) async fn maybe_start_stt_pipeline(
state: &AppState,
ephemeral_channel_id: &str,
) -> Result<bool, String> {
{
let hs = state.huddle()?;
if !hs.transcription_enabled {
return Ok(false);
}
}

if !models::is_stt_ready() {
return Ok(false); // Models not downloaded yet — voice-only mode.
}
Expand Down Expand Up @@ -143,7 +148,9 @@ pub(crate) async fn maybe_start_stt_pipeline(
let mut hs = state.huddle()?;
hs.stt_starting.store(false, Ordering::Release);
// Phase check: huddle may have been torn down during construction.
if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) {
if !hs.transcription_enabled
|| !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active)
{
return Ok(false);
}
hs.stt_pipeline = Some(Arc::clone(&pipeline));
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/huddle/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ pub struct HuddleState {
pub is_creator: bool,
/// Whether TTS output is enabled (user-toggled).
pub tts_enabled: bool,
/// Whether STT transcript posting is enabled for this huddle.
pub transcription_enabled: bool,
/// Shared flag: true while TTS is playing audio.
/// Shared with the STT pipeline for barge-in / echo gating.
#[serde(skip)]
Expand Down Expand Up @@ -154,6 +156,7 @@ impl Clone for HuddleState {
tts_pipeline: None, // Never clone the pipeline handle.
is_creator: self.is_creator,
tts_enabled: self.tts_enabled,
transcription_enabled: self.transcription_enabled,
tts_active: Arc::clone(&self.tts_active),
tts_cancel: Arc::clone(&self.tts_cancel),
tts_starting: Arc::clone(&self.tts_starting),
Expand All @@ -180,6 +183,7 @@ impl Default for HuddleState {
tts_pipeline: None,
is_creator: false,
tts_enabled: true,
transcription_enabled: false,
tts_active: Arc::new(AtomicBool::new(false)),
tts_cancel: Arc::new(AtomicBool::new(false)),
tts_starting: Arc::new(AtomicBool::new(false)),
Expand Down
73 changes: 73 additions & 0 deletions desktop/src-tauri/src/huddle/transcription.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use std::sync::atomic::Ordering;

use tauri::State;

use crate::app_state::AppState;

use super::{models, pipeline::maybe_start_stt_pipeline};

/// Start the STT pipeline for the active huddle.
///
/// Delegates to `maybe_start_stt_pipeline` — returns `Err` if models are not
/// ready or no huddle is active. Safe to call multiple times: replaces the
/// existing pipeline if already running.
#[tauri::command]
pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> {
let ephemeral_channel_id = {
let mut hs = state.huddle()?;
hs.transcription_enabled = true;
hs.ephemeral_channel_id
.clone()
.ok_or("no active huddle — start or join a huddle first")?
};

match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await {
Ok(true) => Ok(()),
Ok(false) => Err("STT model not ready".to_string()),
Err(e) => Err(e),
}
}

/// Enable or disable huddle transcript posting.
///
/// Disabling tears down STT immediately and invalidates any in-flight transcript
/// task before it can post another segment. Enabling starts STT if models are
/// ready; otherwise the hot-start loop will begin transcribing once the model
/// download finishes.
#[tauri::command]
pub async fn set_huddle_transcription_enabled(
enabled: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let (ephemeral_channel_id, old_stt) = {
let mut hs = state.huddle()?;
hs.transcription_enabled = enabled;

if enabled {
(hs.ephemeral_channel_id.clone(), None)
} else {
hs.session_generation.fetch_add(1, Ordering::Release);
hs.stt_starting.store(false, Ordering::Release);
(hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take())
}
};

if let Some(ref pipeline) = old_stt {
pipeline.shutdown();
}
drop(old_stt);

if enabled {
let eph_id =
ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?;
if let Some(manager) = models::global_model_manager() {
manager.start_stt_download(state.http_client.clone());
}
if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await {
eprintln!("buzz-desktop: STT transcript start failed: {e}");
}
}

state.emit_huddle_state_changed();
Ok(())
}
5 changes: 3 additions & 2 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ use huddle::audio_output::{
use huddle::{
add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models,
end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode,
join_huddle, leave_huddle, push_audio_pcm, set_tts_enabled, set_voice_input_mode,
speak_agent_message, start_huddle, start_stt_pipeline,
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 managed_agents::{
backfill_persona_snapshots, ensure_nest, restore_managed_agents_on_launch, try_regenerate_nest,
Expand Down Expand Up @@ -568,6 +568,7 @@ pub fn run() {
get_huddle_state,
push_audio_pcm,
start_stt_pipeline,
set_huddle_transcription_enabled,
download_voice_models,
get_model_status,
set_tts_enabled,
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,12 @@ export function AppShell() {
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
<HuddleBar
className="h-full"
onOpenThread={(channelId, messageId) => {
void goChannel(channelId, {
messageId,
threadRootId: messageId,
});
}}
onVisibilityChange={setIsHuddleDrawerOpen}
/>
</div>
Expand Down
26 changes: 26 additions & 0 deletions desktop/src/features/channels/isDmNotifiableKind.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import assert from "node:assert/strict";
import test from "node:test";

import { isDmNotifiableKind } from "./isDmNotifiableKind.ts";
import {
KIND_HUDDLE_ENDED,
KIND_HUDDLE_PARTICIPANT_JOINED,
KIND_HUDDLE_PARTICIPANT_LEFT,
KIND_HUDDLE_STARTED,
} from "@/shared/constants/kinds";

// Regression guard for the phantom-DM-notification bug: when kind:5 deletes
// gained an `h` tag, they started matching the live DM subscription. Without
Expand All @@ -15,6 +21,11 @@ test("human-visible message kinds fire DM notifications", () => {
assert.equal(isDmNotifiableKind(40002), true, "kind:40002 stream message v2");
assert.equal(isDmNotifiableKind(45001), true, "kind:45001 forum post");
assert.equal(isDmNotifiableKind(45003), true, "kind:45003 forum comment");
assert.equal(
isDmNotifiableKind(KIND_HUDDLE_STARTED),
true,
"kind:48100 huddle start invite",
);
});

test("non-message kinds do NOT fire DM notifications", () => {
Expand All @@ -24,4 +35,19 @@ test("non-message kinds do NOT fire DM notifications", () => {
assert.equal(isDmNotifiableKind(40003), false, "kind:40003 message edit");
assert.equal(isDmNotifiableKind(40008), false, "kind:40008 message diff");
assert.equal(isDmNotifiableKind(40099), false, "kind:40099 system message");
assert.equal(
isDmNotifiableKind(KIND_HUDDLE_PARTICIPANT_JOINED),
false,
"kind:48101 huddle participant joined",
);
assert.equal(
isDmNotifiableKind(KIND_HUDDLE_PARTICIPANT_LEFT),
false,
"kind:48102 huddle participant left",
);
assert.equal(
isDmNotifiableKind(KIND_HUDDLE_ENDED),
false,
"kind:48103 huddle ended",
);
});
15 changes: 12 additions & 3 deletions desktop/src/features/channels/isDmNotifiableKind.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds";
import {
CHANNEL_MESSAGE_EVENT_KINDS,
KIND_HUDDLE_STARTED,
} from "@/shared/constants/kinds";

const DM_NOTIFIABLE_KINDS = new Set<number>(CHANNEL_MESSAGE_EVENT_KINDS);
export const DM_NOTIFIABLE_EVENT_KINDS = [
...CHANNEL_MESSAGE_EVENT_KINDS,
KIND_HUDDLE_STARTED,
] as const;

const DM_NOTIFIABLE_KINDS = new Set<number>(DM_NOTIFIABLE_EVENT_KINDS);

// DM OS-notifications gate. The DM subscription matches every `h`-tagged
// event in the channel (kind:5/7/9005/edits/etc.), so we must filter to
// human-visible message kinds before firing a toast.
// human-visible message kinds before firing a toast. Huddle starts are included
// only for DMs because the start card is the invite.
export function isDmNotifiableKind(kind: number): boolean {
return DM_NOTIFIABLE_KINDS.has(kind);
}
Loading
Loading