Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c5e70d2
feat: add real-time voice dictation to composer
klopez4212 Jul 4, 2026
cc14821
style: make dictation button rounded-full to match send button
klopez4212 Jul 4, 2026
620e36b
fix(relay): add doc comments to transcribe response structs
klopez4212 Jul 4, 2026
3b21adb
fix(relay): add NIP-98 auth + relay membership to transcribe endpoints
klopez4212 Jul 4, 2026
82fc316
fix(dictation): address review — nonce, editor state, segment tracking
klopez4212 Jul 4, 2026
eb4b8a6
fix(dictation): use client_secrets API, track transcripts by item_id
klopez4212 Jul 4, 2026
fd14c3a
fix(dictation): typed transcription session, sync editor before merge…
klopez4212 Jul 5, 2026
e4c5ba6
fix(dictation): stop recording on channel/thread switch
klopez4212 Jul 5, 2026
8b00106
fix: address review comments — nested audio schema, item separators, …
klopez4212 Jul 5, 2026
de1afe0
fix(relay): wrap transcription config in session object
klopez4212 Jul 5, 2026
fc1cef3
fix(dictation): preserve transcript item order from committed events
klopez4212 Jul 5, 2026
646bcbf
fix(dictation): ignore stale transcript events after stop/send
klopez4212 Jul 6, 2026
1643c08
fix(dictation): rate-limit session minting, disable auto-submit by de…
klopez4212 Jul 6, 2026
f07ffd3
fix(dictation): stop dictation before edit-save, remove expect() in o…
klopez4212 Jul 6, 2026
f1db6af
fix: address remaining review comments on dictation PR
klopez4212 Jul 6, 2026
cb504fe
fix: require NIP-98 for transcribe endpoints, allow stop during startup
klopez4212 Jul 6, 2026
089836b
fix(transcribe): gate status on membership, make VAD model-aware
klopez4212 Jul 6, 2026
1380152
fix(dictation): commit buffered audio for manual-VAD models
klopez4212 Jul 6, 2026
8b7eeec
fix(dictation): invalidate run before delayed teardown, remove unwrap
klopez4212 Jul 6, 2026
849140f
fix(dictation): preserve final transcript on user stop, cancel on send
klopez4212 Jul 6, 2026
9839f04
fix(transcribe): set short expires_after on client secrets
klopez4212 Jul 6, 2026
e42bce4
fix(dictation): correct expires_after schema, grace window for all mo…
klopez4212 Jul 6, 2026
aeabf69
fix(dictation): proxy SDP server-side, allow send during transcribing
klopez4212 Jul 6, 2026
bc638f3
fix(dictation): single-request connect, clear transcribing on completion
klopez4212 Jul 6, 2026
e50b669
fix(dictation): handle transcription failed events
klopez4212 Jul 6, 2026
15ee289
refactor(dictation): extract SttEngine, add local Parakeet dictation …
klopez4212 Jul 6, 2026
445daf7
refactor(dictation): drop OpenAI Realtime path, add ⌘D shortcut
klopez4212 Jul 6, 2026
2011a87
feat(dictation): ⌘D push-to-talk, stop button icon when recording
klopez4212 Jul 6, 2026
e4a86ee
fix(dictation): stream transcripts on-the-fly and suppress accent picker
klopez4212 Jul 6, 2026
eddbdb0
fix(dictation): address all review comments — remove dead proxy, scop…
klopez4212 Jul 6, 2026
7bba730
fix(dictation): guard cross-composer cancel, stop engine on unmount, …
klopez4212 Jul 6, 2026
8602f8f
fix(dictation): session-tag transcript events and abort pending starts
klopez4212 Jul 6, 2026
3157497
fix(dictation): tag native events with session ID for definitive filt…
klopez4212 Jul 6, 2026
a9f300f
fix(dictation): capture session ID by value in listener closures
klopez4212 Jul 6, 2026
6497f45
fix(dictation): bail after worklet load if aborted; drop stale PCM on…
Jul 7, 2026
66d0af8
fix(dictation): drain queued audio on shutdown; scope stop to session
Jul 7, 2026
9c3a5bc
fix(dictation): scope all native stops to the owning session
Jul 7, 2026
9badff3
fix(dictation): resume suspended AudioContext; chunk audio under IPC cap
Jul 7, 2026
1aed46e
fix(dictation): cancel during transcribing window + session-scope aud…
Jul 7, 2026
159f223
fix(dictation): abort in-flight start on cleanup/unmount
Jul 7, 2026
fe16803
fix(dictation): serialize audio flushes so stop awaits in-flight IPC
Jul 8, 2026
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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ RELAY_URL=ws://localhost:3000
# (use `just web` for Vite HMR instead).
# BUZZ_WEB_DIR=./web/dist

# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
# Git (NIP-34 bare repositories)
# -----------------------------------------------------------------------------
Expand Down
3 changes: 1 addition & 2 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ pub struct Config {
pub media_max_concurrent_uploads_per_pubkey: u32,
/// Maximum media upload starts accepted from one pubkey per minute.
pub media_uploads_per_minute: u32,

/// Optional override for ephemeral channel TTL (in seconds).
/// When set, any channel created with a TTL tag will use this value instead
/// of the client-provided one. Useful for testing ephemeral expiry quickly.
Expand Down Expand Up @@ -184,7 +183,7 @@ impl Config {
let bind_addr = parse_bind_addr(&bind_addr_raw)?;

let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1

let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
Expand Down
1 change: 1 addition & 0 deletions crates/buzz-relay/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ pub struct AppState {
/// Per-uploader sliding-window rate limiter for media upload starts.
/// Key: uploader pubkey bytes (32). Value: (count, window_start).
pub media_upload_rate_limiter: Arc<DashMap<[u8; 32], (u32, Instant)>>,
/// Per-pubkey sliding-window rate limiter for transcription session minting.
/// Current in-flight media uploads per uploader pubkey.
pub media_uploads_in_flight: Arc<DashMap<[u8; 32], u32>>,
/// Cache for observer agent-owner authorization (kind 24200).
Expand Down
5 changes: 4 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,10 @@ const overrides = new Map([
// + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog
// flow. Load-bearing feature growth; queued to split with the rest of this
// list.
["src/features/messages/ui/MessageComposer.tsx", 1033],
// +15 local-dictation integration: useLocalDictation wiring (mic toggle,
// transcript-append handler, recording/transcribing state, stop-on-send/edit
// guards). Load-bearing feature growth; queued to split with the rest.
["src/features/messages/ui/MessageComposer.tsx", 1049],
]);

await runFileSizeCheck({
Expand Down
10 changes: 5 additions & 5 deletions desktop/src-tauri/src/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use tauri::{AppHandle, Manager};
#[cfg(feature = "mesh-llm")]
use tokio::sync::Mutex as AsyncMutex;

use crate::dictation::DictationState;
use crate::huddle::HuddleState;
use crate::managed_agents::config_bridge::SessionConfigCache;
use crate::managed_agents::ManagedAgentProcess;
Expand All @@ -22,11 +23,7 @@ pub struct AppState {
pub channel_templates_store_lock: Mutex<()>,
pub managed_agent_processes: Mutex<HashMap<String, ManagedAgentProcess>>,
pub huddle_state: Mutex<HuddleState>,
/// 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.
/// Tauri app handle — set once during `setup()`, 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.
Expand All @@ -46,6 +43,8 @@ pub struct AppState {
/// listener is up before any restore/create can request a connection.
#[cfg(feature = "mesh-llm")]
pub mesh_coordinator: AsyncMutex<Option<crate::mesh_llm::MeshCoordinator>>,
/// Local dictation state (Parakeet STT for composer voice input).
pub dictation_state: Mutex<DictationState>,
}

/// Parse the `BUZZ_PRIVATE_KEY` env var into identity keys. `Some` means the
Expand Down Expand Up @@ -108,6 +107,7 @@ pub fn build_app_state() -> AppState {
mesh_llm_runtime: AsyncMutex::new(None),
#[cfg(feature = "mesh-llm")]
mesh_coordinator: AsyncMutex::new(None),
dictation_state: Mutex::new(DictationState::new()),
}
}

Expand Down
276 changes: 276 additions & 0 deletions desktop/src-tauri/src/dictation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
//! Local dictation pipeline — uses the Parakeet STT engine for offline
//! speech-to-text in the message composer.
//!
//! Unlike the huddle STT pipeline (which posts kind:9 events to the relay),
//! dictation emits transcribed text back to the frontend via Tauri events
//! so the composer can display it in real-time.
//!
//! Key differences from huddle STT:
//! - No TTS barge-in / echo gating (no agent voice in composer context)
//! - No PTT (dictation uses a toggle button, not push-to-talk)
//! - Slightly longer silence threshold for more coherent sentences
//! - Text goes to the frontend, not to the relay

use std::sync::Arc;

use tauri::{Emitter, State};

use crate::app_state::AppState;
use crate::huddle::models;
use crate::stt_engine::{
SttEngine, SttEngineConfig, DEFAULT_MAX_SPEECH_SAMPLES, DICTATION_PARTIAL_FLUSH_SAMPLES,
DICTATION_SILENCE_FLUSH_FRAMES,
};

/// Tauri event name emitted when a dictation transcript segment is ready.
const DICTATION_TRANSCRIPT_EVENT: &str = "dictation-transcript";

/// Tauri event name emitted when dictation state changes (started/stopped).
const DICTATION_STATE_EVENT: &str = "dictation-state";

/// State for the active dictation session.
///
/// Stored in `AppState` behind a `Mutex`. Only one dictation session can be
/// active at a time (starting a new one stops the previous).
pub(crate) struct DictationState {
/// The running STT engine, if dictation is active.
engine: Option<Arc<SttEngine>>,
/// Monotonically increasing session counter. Included in all emitted events
/// so the frontend can ignore stale transcripts from a previous session's
/// forwarder that arrive after a new session has started.
session_id: u64,
}

impl DictationState {
pub fn new() -> Self {
Self {
engine: None,
session_id: 0,
}
}
}

/// `start_dictation` — begin local STT dictation.
///
/// Starts the Parakeet STT engine and spawns a task that emits
/// `dictation-transcript` events to the frontend as text is recognized.
/// Returns an error if models are not downloaded yet.
#[tauri::command]
pub async fn start_dictation(state: State<'_, AppState>) -> Result<u64, String> {
// Check if models are ready.
if !models::is_stt_ready() {
// Kick off download if not already in progress.
if let Some(mgr) = models::global_model_manager() {
mgr.start_stt_download(state.http_client.clone());
}
return Err("STT model not ready — download in progress".to_string());
}

let model_dir = models::stt_model_dir().ok_or("STT model directory not found")?;

// Stop any existing dictation session first.
stop_dictation_inner(&state, None);

let config = SttEngineConfig {
model_dir,
silence_flush_frames: DICTATION_SILENCE_FLUSH_FRAMES,
max_speech_samples: DEFAULT_MAX_SPEECH_SAMPLES,
partial_flush_samples: Some(DICTATION_PARTIAL_FLUSH_SAMPLES),
tts_active: None,
tts_cancel: None,
ptt_active: None,
};

let (engine, text_rx) = SttEngine::new(config)?;
let engine = Arc::new(engine);

// Store the engine in state and increment the session counter.
let session_id = {
let mut ds = state
.dictation_state
.lock()
.unwrap_or_else(|e| e.into_inner());
ds.engine = Some(Arc::clone(&engine));
ds.session_id += 1;
ds.session_id
};

// Spawn a task that forwards transcribed text to the frontend.
let app_handle = state
.app_handle
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();

if let Some(handle) = app_handle {
let _ = handle.emit(
DICTATION_STATE_EVENT,
serde_json::json!({ "state": "started", "session": session_id }),
);
spawn_dictation_forwarder(text_rx, handle, session_id);
}

Ok(session_id)
}

/// `stop_dictation` — stop the active dictation session.
///
/// The final transcript (if any) is emitted asynchronously by the forwarder
/// task. The `dictation-state: stopped` event is emitted by the forwarder
/// after all pending transcripts have been forwarded, ensuring the frontend
/// receives the final text before the stopped signal.
///
/// `session` scopes the stop to a specific session: the engine is only torn
/// down when the currently-stored `session_id` matches. This prevents a
/// delayed/fire-and-forget stop from an old session (e.g. one deferred behind
/// a final audio flush) from killing a *newer* session the user started in the
/// meantime. Pass `None` for an unconditional stop (used on cancel/unmount).
#[tauri::command]
pub fn stop_dictation(session: Option<u64>, state: State<'_, AppState>) -> Result<(), String> {
stop_dictation_inner(&state, session);
// Note: `stopped` is emitted by the forwarder task after draining all
// pending transcripts — not here. This avoids a race where the frontend
// sees `stopped` before the final transcript arrives.
Ok(())
}

/// `push_dictation_audio` — feed raw PCM bytes into the dictation pipeline.
///
/// Expects a raw binary body: an 8-byte little-endian `u64` session header
/// followed by f32 LE samples at 48 kHz mono. The header scopes the push to a
/// specific session — bytes are only fed to the engine when the header matches
/// the currently-stored `session_id`. This prevents late audio from a
/// just-stopped session (whose final `flushAudioBatch()` chunks are still
/// arriving) from being accepted by a *newer* session the user started in the
/// meantime and transcribed into the new draft.
///
/// If no dictation session is active, or the session header doesn't match the
/// active session, the bytes are silently discarded.
#[tauri::command]
pub fn push_dictation_audio(
request: tauri::ipc::Request<'_>,
state: State<'_, AppState>,
) -> Result<(), String> {
/// Size of the leading little-endian `u64` session header.
const SESSION_HEADER_BYTES: usize = 8;
/// Maximum IPC audio batch size (audio payload only, excluding the header): 100 KB.
const MAX_AUDIO_BATCH_BYTES: usize = 100 * 1024;

match request.body() {
tauri::ipc::InvokeBody::Raw(bytes) => {
if bytes.len() < SESSION_HEADER_BYTES {
return Err(format!(
"audio batch too small: {} bytes (need at least {} for session header)",
bytes.len(),
SESSION_HEADER_BYTES
));
}
let (header, audio) = bytes.split_at(SESSION_HEADER_BYTES);
if audio.len() > MAX_AUDIO_BATCH_BYTES {
return Err(format!(
"audio batch too large: {} bytes (max {})",
audio.len(),
MAX_AUDIO_BATCH_BYTES
));
}
// `split_at` guarantees `header` is exactly `SESSION_HEADER_BYTES` long.
let session = u64::from_le_bytes(
header
.try_into()
.map_err(|_| "invalid session header".to_string())?,
);
let ds = state
.dictation_state
.lock()
.unwrap_or_else(|e| e.into_inner());
// Only feed audio tagged with the currently-active session. Late
// chunks from an old session are silently dropped.
if session == ds.session_id {
if let Some(ref engine) = ds.engine {
engine.push_audio(audio.to_vec())?;
}
}
Ok(())
}
_ => Err("expected raw binary body".to_string()),
}
}

/// `get_dictation_status` — check if local dictation is available and/or active.
#[tauri::command]
pub fn get_dictation_status(state: State<'_, AppState>) -> DictationStatus {
let model_ready = models::is_stt_ready();
let is_active = state
.dictation_state
.lock()
.unwrap_or_else(|e| e.into_inner())
.engine
.is_some();

DictationStatus {
available: model_ready,
active: is_active,
}
}

/// Response for `get_dictation_status`.
#[derive(serde::Serialize, Clone)]
pub struct DictationStatus {
/// Whether the local STT model is downloaded and ready.
pub available: bool,
/// Whether a dictation session is currently active.
pub active: bool,
}

// ── Internal helpers ──────────────────────────────────────────────────────────

fn stop_dictation_inner(state: &AppState, session: Option<u64>) {
let old_engine = {
let mut ds = state
.dictation_state
.lock()
.unwrap_or_else(|e| e.into_inner());
// Session-scoped stop: only tear down when the requested session matches
// the one currently stored. A `None` session stops unconditionally.
match session {
Some(requested) if requested != ds.session_id => None,
_ => ds.engine.take(),
}
};
if let Some(engine) = old_engine {
engine.shutdown();
// Drop outside the lock — thread join may block briefly.
drop(engine);
}
}

/// Spawn an async task that reads transcribed text and emits Tauri events.
///
/// Each event includes the `session` ID so the frontend can ignore stale
/// transcripts from a previous session's forwarder. When the channel closes
/// (engine stopped), the forwarder emits `dictation-state: stopped`.
fn spawn_dictation_forwarder(
mut text_rx: tokio::sync::mpsc::Receiver<String>,
app_handle: tauri::AppHandle,
session_id: u64,
) {
tauri::async_runtime::spawn(async move {
while let Some(text) = text_rx.recv().await {
if text.is_empty() {
continue;
}
let payload = serde_json::json!({ "text": text, "session": session_id });
if app_handle
.emit(DICTATION_TRANSCRIPT_EVENT, payload)
.is_err()
{
break; // App window closed.
}
}
// All transcripts forwarded — signal the frontend that dictation is done.
let _ = app_handle.emit(
DICTATION_STATE_EVENT,
serde_json::json!({ "state": "stopped", "session": session_id }),
);
});
}
19 changes: 2 additions & 17 deletions desktop/src-tauri/src/huddle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,8 @@ pub mod wire;

// ── Shared utilities ──────────────────────────────────────────────────────────

/// Drain and discard all pending messages until shutdown or disconnect.
/// Shared by both the STT and TTS worker threads for graceful degradation
/// when model files are missing or initialization fails.
pub(super) fn drain_until_shutdown<T>(
rx: std::sync::mpsc::Receiver<T>,
shutdown: &std::sync::atomic::AtomicBool,
) {
loop {
if shutdown.load(std::sync::atomic::Ordering::Acquire) {
break;
}
match rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(_) => continue,
Err(_) => break,
}
}
}
/// Re-export from `stt_engine` for backward compatibility with `tts.rs`.
pub(super) use crate::stt_engine::drain_until_shutdown;

// ── Re-exports ────────────────────────────────────────────────────────────────

Expand Down
Loading
Loading