diff --git a/client/Cargo.toml b/client/Cargo.toml index 01e8c65..7b542e8 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.32" +version = "0.11.33" edition = "2024" [dependencies] diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index b185a9d..05d54bd 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -111,8 +111,8 @@ const LESAVKA_ICON_SEARCH_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ass const LAUNCHER_DEFAULT_WIDTH: i32 = 1380; const LAUNCHER_DEFAULT_HEIGHT: i32 = 860; const OPERATIONS_RAIL_WIDTH: i32 = 288; -const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 108; -const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 192; +const CAMERA_PREVIEW_VIEWPORT_HEIGHT: i32 = 144; +const CAMERA_PREVIEW_VIEWPORT_WIDTH: i32 = 256; pub fn build_launcher_view( app: >k::Application, @@ -306,11 +306,10 @@ pub fn build_launcher_view( µphone_test_button, ); - let audio_check_detail = gtk::Label::new(Some( - "Monitor Mic listens locally, Replay Last 3s replays the latest captured mic audio, and Play Tone verifies the speaker path.", - )); + let audio_check_detail = gtk::Label::new(Some("Idle")); audio_check_detail.add_css_class("dim-label"); - audio_check_detail.set_wrap(true); + audio_check_detail.set_wrap(false); + audio_check_detail.set_ellipsize(pango::EllipsizeMode::End); audio_check_detail.set_xalign(0.0); let audio_check_meter = gtk::ProgressBar::new(); audio_check_meter.add_css_class("audio-check-meter"); @@ -322,11 +321,15 @@ pub fn build_launcher_view( preview_panel.set_hexpand(true); preview_panel.set_vexpand(false); preview_panel.set_valign(gtk::Align::Fill); - preview_body.set_vexpand(true); - preview_body.set_spacing(6); + preview_body.set_vexpand(false); + preview_body.set_spacing(8); + let testing_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + testing_row.set_hexpand(true); + testing_row.set_vexpand(false); + testing_row.set_valign(gtk::Align::Start); let camera_preview = gtk::Picture::new(); camera_preview.set_can_shrink(false); - camera_preview.set_hexpand(true); + camera_preview.set_hexpand(false); camera_preview.set_vexpand(false); camera_preview.set_size_request( CAMERA_PREVIEW_VIEWPORT_WIDTH, @@ -339,40 +342,56 @@ pub fn build_launcher_view( camera_status.set_wrap(false); camera_status.set_ellipsize(pango::EllipsizeMode::End); camera_status.set_xalign(0.0); - camera_status.set_visible(true); + camera_status.set_visible(false); let camera_preview_shell = gtk::Box::new(gtk::Orientation::Vertical, 0); - camera_preview_shell.set_hexpand(true); + camera_preview_shell.set_hexpand(false); camera_preview_shell.set_vexpand(false); - camera_preview_shell.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT); + camera_preview_shell.set_halign(gtk::Align::Center); + camera_preview_shell.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); let camera_preview_frame = gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false); - camera_preview_frame.set_hexpand(true); + camera_preview_frame.set_hexpand(false); camera_preview_frame.set_vexpand(false); - camera_preview_frame.set_size_request(-1, CAMERA_PREVIEW_VIEWPORT_HEIGHT); + camera_preview_frame.set_size_request( + CAMERA_PREVIEW_VIEWPORT_WIDTH, + CAMERA_PREVIEW_VIEWPORT_HEIGHT, + ); camera_preview_frame.set_child(Some(&camera_preview)); camera_preview_shell.append(&camera_preview_frame); let webcam_group = build_subgroup("Webcam Preview"); + webcam_group.set_hexpand(true); + webcam_group.set_vexpand(false); + webcam_group.set_valign(gtk::Align::Start); webcam_group.append(&camera_preview_shell); - webcam_group.append(&camera_status); - preview_body.append(&webcam_group); + testing_row.append(&webcam_group); let playback_group = build_subgroup("Mic Playback"); - playback_group.set_vexpand(true); - playback_group.set_valign(gtk::Align::Fill); - let playback_body = gtk::Box::new(gtk::Orientation::Vertical, 6); - let playback_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); - playback_row.set_homogeneous(false); + playback_group.set_hexpand(false); + playback_group.set_vexpand(false); + playback_group.set_valign(gtk::Align::Start); + playback_group.set_size_request(148, -1); + let playback_body = gtk::Box::new(gtk::Orientation::Horizontal, 8); + playback_body.set_vexpand(false); + let playback_controls = gtk::Box::new(gtk::Orientation::Vertical, 6); + playback_controls.set_hexpand(true); let microphone_replay_button = gtk::Button::with_label("Replay Last 3s"); stabilize_button(µphone_replay_button, 124); - audio_check_meter.set_hexpand(true); - audio_check_meter.set_show_text(true); + audio_check_meter.set_orientation(gtk::Orientation::Vertical); + audio_check_meter.set_inverted(true); + audio_check_meter.set_hexpand(false); + audio_check_meter.set_vexpand(false); + audio_check_meter.set_size_request(20, CAMERA_PREVIEW_VIEWPORT_HEIGHT - 38); + audio_check_meter.set_show_text(false); audio_check_meter.set_text(Some("Idle")); - playback_row.append(µphone_replay_button); - playback_row.append(&audio_check_meter); - playback_body.append(&playback_row); - audio_check_detail.set_visible(false); - playback_body.append(&audio_check_detail); + playback_controls.append(µphone_replay_button); + playback_controls.append(&audio_check_detail); + playback_body.append(&audio_check_meter); + playback_body.append(&playback_controls); playback_group.append(&playback_body); - preview_body.append(&playback_group); + testing_row.append(&playback_group); + preview_body.append(&testing_row); staging_row.append(&preview_panel); let (connection_panel, connection_body) = build_panel("Session"); @@ -817,10 +836,14 @@ pub fn install_css(window: >k::ApplicationWindow) { padding: 10px; } progressbar.audio-check-meter trough { + min-width: 14px; min-height: 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.08); } + progressbar.audio-check-meter.vertical trough { + min-height: 96px; + } progressbar.audio-check-meter progress { border-radius: 999px; background: rgba(91, 179, 162, 0.88); diff --git a/common/Cargo.toml b/common/Cargo.toml index 1d77d5c..ce281eb 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.32" +version = "0.11.33" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 3c2806b..bd6c11b 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -17,6 +17,6 @@ mod tests { #[test] fn banner_includes_version() { - assert_eq!(banner("0.11.32"), "lesavka-common CLI (v0.11.32)"); + assert_eq!(banner("0.11.33"), "lesavka-common CLI (v0.11.33)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index dc13666..667aa97 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.32" +version = "0.11.33" edition = "2024" autobins = false diff --git a/server/src/audio.rs b/server/src/audio.rs index abfaeab..c9fece8 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -9,11 +9,14 @@ use gst::MessageView::*; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; -use std::sync::{Arc, Mutex}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, +}; use std::time::{Duration, Instant}; use tokio_stream::wrappers::ReceiverStream; use tonic::Status; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, warn}; use lesavka_common::lesavka::AudioPacket; @@ -96,6 +99,7 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { // }); let (tx, rx) = tokio::sync::mpsc::channel(8192); + let source_health = Arc::new(AudioSourceHealth::new()); let bus = pipeline.bus().expect("bus"); std::thread::spawn(move || { @@ -114,6 +118,15 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { StateChanged(s) if s.current() == gst::State::Playing => { debug!("🎢 audio pipeline PLAYING") } + Element(e) => { + if let Some(structure) = e.structure() { + if structure.name() == "level" { + info!("πŸ”Š source audio level {}", structure); + } else { + debug!("πŸ”Ž audio element message: {}", structure); + } + } + } _ => {} } } @@ -124,10 +137,16 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { gst_app::AppSinkCallbacks::builder() .new_sample({ let tap = tap.clone(); + let source_health = source_health.clone(); + let tx = tx.clone(); move |s| { + if source_health.is_closed() { + return Err(gst::FlowError::Flushing); + } let sample = s.pull_sample().map_err(|_| gst::FlowError::Eos)?; let buffer = sample.buffer().ok_or(gst::FlowError::Error)?; let map = buffer.map_readable().map_err(|_| gst::FlowError::Error)?; + source_health.mark_sample(); // -------- clip‑tap (minute dumps) ------------ tap.lock().unwrap().feed(map.as_slice()); @@ -166,6 +185,13 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result { .set_state(gst::State::Playing) .context("starting audio pipeline")?; + spawn_audio_source_watchdog( + pipeline.clone(), + source_health, + tx.clone(), + alsa_dev.to_string(), + ); + Ok(AudioStream { _pipeline: pipeline, inner: ReceiverStream::new(rx), @@ -188,8 +214,10 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result { Ok(format!( concat!( - "alsasrc device=\"{dev}\" do-timestamp=true ! ", + "alsasrc device=\"{dev}\" do-timestamp=true provide-clock=false ", + "use-driver-timestamps=false buffer-time=200000 latency-time=10000 ! ", "audio/x-raw,format=S16LE,channels=2,rate=48000 ! ", + "level name=source_level interval=1000000000 message=true ! ", "audioconvert ! audioresample ! {enc} bitrate=192000 ! ", "aacparse ! ", "capsfilter caps=audio/mpeg,stream-format=adts,channels=2,rate=48000 ! ", @@ -202,6 +230,147 @@ fn build_pipeline_desc(dev: &str) -> anyhow::Result { )) } +#[cfg(not(coverage))] +struct AudioSourceHealth { + started_at: Instant, + last_sample_at: Mutex, + packets: AtomicU64, + closed: AtomicBool, +} + +#[cfg(not(coverage))] +impl AudioSourceHealth { + fn new() -> Self { + let now = Instant::now(); + Self { + started_at: now, + last_sample_at: Mutex::new(now), + packets: AtomicU64::new(0), + closed: AtomicBool::new(false), + } + } + + fn mark_sample(&self) { + self.packets.fetch_add(1, Ordering::Relaxed); + if let Ok(mut last) = self.last_sample_at.lock() { + *last = Instant::now(); + } + } + + fn is_closed(&self) -> bool { + self.closed.load(Ordering::Relaxed) + } + + fn signal_failure(&self) -> bool { + !self.closed.swap(true, Ordering::Relaxed) + } + + fn elapsed(&self) -> Duration { + self.started_at.elapsed() + } + + fn idle_for(&self) -> Duration { + self.last_sample_at + .lock() + .map(|last| last.elapsed()) + .unwrap_or_else(|_| Duration::from_secs(0)) + } + + fn packets(&self) -> u64 { + self.packets.load(Ordering::Relaxed) + } +} + +#[cfg(not(coverage))] +#[derive(Clone, Copy)] +struct AudioWatchdogPolicy { + startup_grace: Duration, + idle_timeout: Duration, + min_packets_per_second: u64, +} + +#[cfg(not(coverage))] +impl AudioWatchdogPolicy { + fn from_env() -> Self { + Self { + startup_grace: env_duration_ms("LESAVKA_AUDIO_SOURCE_GRACE_MS", 3_000), + idle_timeout: env_duration_ms("LESAVKA_AUDIO_SOURCE_IDLE_MS", 1_500), + min_packets_per_second: env_u64("LESAVKA_AUDIO_MIN_PACKETS_PER_SEC", 20), + } + } +} + +#[cfg(not(coverage))] +fn env_duration_ms(name: &str, default_ms: u64) -> Duration { + Duration::from_millis(env_u64(name, default_ms)) +} + +#[cfg(not(coverage))] +fn env_u64(name: &str, default: u64) -> u64 { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +/// Watch the remote speaker capture source and fail fast when the USB audio +/// gadget is open but not producing real-time packets. +#[cfg(not(coverage))] +fn spawn_audio_source_watchdog( + pipeline: gst::Pipeline, + health: Arc, + tx: tokio::sync::mpsc::Sender>, + alsa_dev: String, +) { + let policy = AudioWatchdogPolicy::from_env(); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_millis(250)); + if health.is_closed() { + break; + } + + let elapsed = health.elapsed(); + if elapsed < policy.startup_grace { + continue; + } + + let packets = health.packets(); + let idle_for = health.idle_for(); + let rate = packets as f64 / elapsed.as_secs_f64().max(0.001); + + let failure = if packets == 0 { + Some(format!( + "remote speaker capture produced no audio samples after {} ms on {alsa_dev}", + elapsed.as_millis() + )) + } else if idle_for >= policy.idle_timeout { + Some(format!( + "remote speaker capture stalled for {} ms on {alsa_dev}", + idle_for.as_millis() + )) + } else if (packets / elapsed.as_secs().max(1)) < policy.min_packets_per_second { + Some(format!( + "remote speaker capture cadence is too low on {alsa_dev}: {rate:.1} packets/s, expected at least {} packets/s", + policy.min_packets_per_second + )) + } else { + None + }; + + if let Some(message) = failure { + if health.signal_failure() { + warn!("πŸ”ŠπŸ›Ÿ {message}; restarting audio capture on next client reconnect"); + let _ = pipeline.set_state(gst::State::Null); + let _ = tx.blocking_send(Err(Status::unavailable(message))); + } + break; + } + } + }); +} + // ────────────────────── minute‑clip helper ─────────────────────────────── pub struct ClipTap { buf: Vec,