2026-04-15 01:20:51 -03:00
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
|
|
|
use gst::prelude::*;
|
|
|
|
|
use gstreamer as gst;
|
|
|
|
|
use gstreamer_app as gst_app;
|
|
|
|
|
use gtk::{gdk, glib};
|
2026-04-14 23:03:18 -03:00
|
|
|
use shell_escape::escape;
|
|
|
|
|
use std::borrow::Cow;
|
2026-04-16 12:58:05 -03:00
|
|
|
use std::fs;
|
2026-04-22 05:46:31 -03:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-04-14 23:03:18 -03:00
|
|
|
use std::process::{Child, Command};
|
2026-04-15 01:20:51 -03:00
|
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
use super::devices::CameraMode;
|
|
|
|
|
|
2026-04-23 04:46:21 -03:00
|
|
|
const CAMERA_PREVIEW_DEFAULT_WIDTH: i32 = 1280;
|
|
|
|
|
const CAMERA_PREVIEW_DEFAULT_HEIGHT: i32 = 720;
|
|
|
|
|
const CAMERA_PREVIEW_DEFAULT_FPS: u32 = 30;
|
2026-04-20 11:11:51 -03:00
|
|
|
const CAMERA_PREVIEW_IDLE: &str = "Select a webcam and click Start Preview.";
|
2026-04-16 12:58:05 -03:00
|
|
|
const MIC_MONITOR_RATE: i32 = 16_000;
|
|
|
|
|
const MIC_MONITOR_CHANNELS: i32 = 1;
|
|
|
|
|
const MIC_MONITOR_SAMPLE_BYTES: usize = 2;
|
|
|
|
|
const MIC_REPLAY_SECONDS: usize = 3;
|
|
|
|
|
const MIC_REPLAY_PATH: &str = "/tmp/lesavka-mic-replay.wav";
|
|
|
|
|
const MIC_REPLAY_MAX_BYTES: usize = MIC_MONITOR_RATE as usize
|
|
|
|
|
* MIC_MONITOR_CHANNELS as usize
|
|
|
|
|
* MIC_MONITOR_SAMPLE_BYTES
|
|
|
|
|
* MIC_REPLAY_SECONDS;
|
2026-04-14 23:03:18 -03:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum DeviceTestKind {
|
|
|
|
|
Camera,
|
|
|
|
|
Microphone,
|
2026-04-16 12:58:05 -03:00
|
|
|
MicrophoneReplay,
|
2026-04-14 23:03:18 -03:00
|
|
|
Speaker,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct DeviceTestController {
|
2026-04-15 01:20:51 -03:00
|
|
|
camera: Option<LocalCameraPreview>,
|
|
|
|
|
selected_camera: Option<String>,
|
2026-04-22 22:10:39 -03:00
|
|
|
selected_camera_mode: Option<CameraMode>,
|
2026-04-16 12:58:05 -03:00
|
|
|
microphone: Option<LocalMicrophoneMonitor>,
|
2026-04-22 05:46:31 -03:00
|
|
|
microphone_probe: Option<LocalMicrophoneLevelProbe>,
|
2026-04-14 23:03:18 -03:00
|
|
|
speaker: Option<Child>,
|
2026-04-16 12:58:05 -03:00
|
|
|
microphone_replay: Option<Child>,
|
|
|
|
|
microphone_buffer: Arc<Mutex<Vec<u8>>>,
|
|
|
|
|
microphone_level: Arc<Mutex<f64>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for DeviceTestController {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
camera: None,
|
|
|
|
|
selected_camera: None,
|
2026-04-22 22:10:39 -03:00
|
|
|
selected_camera_mode: None,
|
2026-04-16 12:58:05 -03:00
|
|
|
microphone: None,
|
2026-04-22 05:46:31 -03:00
|
|
|
microphone_probe: None,
|
2026-04-16 12:58:05 -03:00
|
|
|
speaker: None,
|
|
|
|
|
microphone_replay: None,
|
|
|
|
|
microphone_buffer: Arc::new(Mutex::new(Vec::new())),
|
|
|
|
|
microphone_level: Arc::new(Mutex::new(0.0)),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl DeviceTestController {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
pub fn bind_camera_preview(
|
|
|
|
|
&mut self,
|
|
|
|
|
camera_picture: >k::Picture,
|
|
|
|
|
camera_status: >k::Label,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
if let Some(camera) = self.camera.as_mut() {
|
|
|
|
|
camera.stop();
|
|
|
|
|
}
|
|
|
|
|
let mut preview = LocalCameraPreview::new(camera_picture, camera_status);
|
|
|
|
|
preview.set_selected(self.selected_camera.as_deref())?;
|
2026-04-22 22:10:39 -03:00
|
|
|
preview.set_selected_mode(self.selected_camera_mode)?;
|
2026-04-15 01:20:51 -03:00
|
|
|
self.camera = Some(preview);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
pub fn is_running(&mut self, kind: DeviceTestKind) -> bool {
|
|
|
|
|
self.cleanup_finished();
|
2026-04-15 01:20:51 -03:00
|
|
|
match kind {
|
|
|
|
|
DeviceTestKind::Camera => self
|
|
|
|
|
.camera
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(LocalCameraPreview::is_running),
|
2026-04-22 05:46:31 -03:00
|
|
|
DeviceTestKind::Microphone => {
|
|
|
|
|
self.microphone
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(LocalMicrophoneMonitor::is_running)
|
|
|
|
|
|| self
|
|
|
|
|
.microphone_probe
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(LocalMicrophoneLevelProbe::is_running)
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
DeviceTestKind::MicrophoneReplay => self.microphone_replay.is_some(),
|
2026-04-15 01:20:51 -03:00
|
|
|
DeviceTestKind::Speaker => self.speaker.is_some(),
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
pub fn set_camera_selection(&mut self, camera: Option<&str>) -> Result<()> {
|
|
|
|
|
self.selected_camera = normalize_camera_selection(camera);
|
|
|
|
|
if let Some(preview) = self.camera.as_mut() {
|
|
|
|
|
preview.set_selected(self.selected_camera.as_deref())?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
pub fn set_camera_quality(&mut self, mode: Option<CameraMode>) -> Result<()> {
|
|
|
|
|
self.selected_camera_mode = mode;
|
|
|
|
|
if let Some(preview) = self.camera.as_mut() {
|
|
|
|
|
preview.set_selected_mode(mode)?;
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
pub fn toggle_camera(&mut self) -> Result<bool> {
|
|
|
|
|
let preview = self
|
|
|
|
|
.camera
|
|
|
|
|
.as_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?;
|
|
|
|
|
preview.toggle()
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn toggle_microphone(&mut self, source: Option<&str>, sink: Option<&str>) -> Result<bool> {
|
2026-04-16 12:58:05 -03:00
|
|
|
self.cleanup_finished();
|
|
|
|
|
if self.microphone.is_some() {
|
|
|
|
|
self.stop(DeviceTestKind::Microphone);
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let monitor = LocalMicrophoneMonitor::start(
|
|
|
|
|
source,
|
|
|
|
|
sink,
|
|
|
|
|
Arc::clone(&self.microphone_buffer),
|
|
|
|
|
Arc::clone(&self.microphone_level),
|
|
|
|
|
)?;
|
|
|
|
|
self.microphone = Some(monitor);
|
|
|
|
|
Ok(true)
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
pub fn stop_local_capture_for_relay(&mut self) {
|
|
|
|
|
if self
|
|
|
|
|
.camera
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(LocalCameraPreview::is_device_preview_running)
|
|
|
|
|
&& let Some(camera) = self.camera.as_mut()
|
|
|
|
|
{
|
|
|
|
|
camera.stop();
|
|
|
|
|
}
|
|
|
|
|
if let Some(mut monitor) = self.microphone.take() {
|
|
|
|
|
monitor.stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn sync_relay_uplink_probe(
|
|
|
|
|
&mut self,
|
|
|
|
|
relay_live: bool,
|
|
|
|
|
camera_active: bool,
|
|
|
|
|
camera_label: Option<&str>,
|
|
|
|
|
camera_preview_path: &Path,
|
|
|
|
|
microphone_active: bool,
|
|
|
|
|
microphone_level_path: &Path,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
self.cleanup_finished();
|
|
|
|
|
let camera_should_probe = relay_live && camera_active && camera_label.is_some();
|
|
|
|
|
if camera_should_probe {
|
|
|
|
|
let preview = self
|
|
|
|
|
.camera
|
|
|
|
|
.as_mut()
|
|
|
|
|
.ok_or_else(|| anyhow!("camera preview panel is not ready yet"))?;
|
|
|
|
|
preview.start_relay_file(
|
|
|
|
|
camera_preview_path.to_path_buf(),
|
|
|
|
|
camera_label.unwrap_or("selected webcam").to_string(),
|
|
|
|
|
)?;
|
|
|
|
|
} else if self
|
|
|
|
|
.camera
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(LocalCameraPreview::is_relay_file_running)
|
|
|
|
|
&& let Some(preview) = self.camera.as_mut()
|
|
|
|
|
{
|
|
|
|
|
preview.stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let microphone_should_probe = relay_live && microphone_active;
|
|
|
|
|
if microphone_should_probe {
|
|
|
|
|
if self.microphone.is_some() {
|
|
|
|
|
self.stop(DeviceTestKind::Microphone);
|
|
|
|
|
}
|
|
|
|
|
let needs_probe = self
|
|
|
|
|
.microphone_probe
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_none_or(|probe| !probe.is_running_for(microphone_level_path));
|
|
|
|
|
if needs_probe {
|
|
|
|
|
self.stop_microphone_probe();
|
|
|
|
|
self.microphone_probe = Some(LocalMicrophoneLevelProbe::start(
|
|
|
|
|
microphone_level_path.to_path_buf(),
|
|
|
|
|
Arc::clone(&self.microphone_level),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
self.stop_microphone_probe();
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 23:03:18 -03:00
|
|
|
pub fn toggle_speaker(&mut self, sink: Option<&str>) -> Result<bool> {
|
2026-04-16 12:58:05 -03:00
|
|
|
self.toggle_child(DeviceTestKind::Speaker, build_speaker_test(sink))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn toggle_microphone_replay(&mut self, sink: Option<&str>) -> Result<bool> {
|
|
|
|
|
self.cleanup_finished();
|
|
|
|
|
if self.microphone_replay.is_some() {
|
|
|
|
|
self.stop(DeviceTestKind::MicrophoneReplay);
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let wav_bytes = self.replay_wav_bytes()?;
|
|
|
|
|
fs::write(MIC_REPLAY_PATH, wav_bytes).context("writing microphone replay clip")?;
|
|
|
|
|
let child = build_microphone_replay_test(MIC_REPLAY_PATH, sink)?
|
|
|
|
|
.spawn()
|
|
|
|
|
.context("starting microphone replay")?;
|
|
|
|
|
self.microphone_replay = Some(child);
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn microphone_level_fraction(&mut self) -> f64 {
|
|
|
|
|
self.cleanup_finished();
|
|
|
|
|
self.microphone_level
|
|
|
|
|
.lock()
|
|
|
|
|
.map(|value| (*value).clamp(0.0, 1.0))
|
|
|
|
|
.unwrap_or(0.0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn microphone_replay_ready(&mut self) -> bool {
|
|
|
|
|
self.cleanup_finished();
|
|
|
|
|
self.microphone_buffer
|
|
|
|
|
.lock()
|
|
|
|
|
.map(|buffer| !buffer.is_empty())
|
|
|
|
|
.unwrap_or(false)
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn stop_all(&mut self) {
|
2026-04-15 01:20:51 -03:00
|
|
|
if let Some(camera) = self.camera.as_mut() {
|
|
|
|
|
camera.stop();
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
for kind in [
|
|
|
|
|
DeviceTestKind::Microphone,
|
|
|
|
|
DeviceTestKind::MicrophoneReplay,
|
|
|
|
|
DeviceTestKind::Speaker,
|
|
|
|
|
] {
|
2026-04-14 23:03:18 -03:00
|
|
|
self.stop(kind);
|
|
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
self.stop_microphone_probe();
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn toggle_child(&mut self, kind: DeviceTestKind, command: Result<Command>) -> Result<bool> {
|
2026-04-14 23:03:18 -03:00
|
|
|
self.cleanup_finished();
|
|
|
|
|
if self.slot(kind).is_some() {
|
|
|
|
|
self.stop(kind);
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
let child = command?
|
|
|
|
|
.spawn()
|
|
|
|
|
.with_context(|| format!("starting {kind:?} test"))?;
|
|
|
|
|
*self.slot_mut(kind) = Some(child);
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stop(&mut self, kind: DeviceTestKind) {
|
2026-04-16 12:58:05 -03:00
|
|
|
match kind {
|
|
|
|
|
DeviceTestKind::Camera => panic!("camera preview is not stopped through this path"),
|
|
|
|
|
DeviceTestKind::Microphone => {
|
|
|
|
|
if let Some(mut monitor) = self.microphone.take() {
|
|
|
|
|
monitor.stop();
|
|
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
self.stop_microphone_probe();
|
2026-04-16 12:58:05 -03:00
|
|
|
if let Ok(mut level) = self.microphone_level.lock() {
|
|
|
|
|
*level = 0.0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
DeviceTestKind::MicrophoneReplay | DeviceTestKind::Speaker => {
|
|
|
|
|
if let Some(mut child) = self.slot_mut(kind).take() {
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
let _ = child.wait();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn cleanup_finished(&mut self) {
|
2026-04-16 12:58:05 -03:00
|
|
|
if self
|
|
|
|
|
.microphone
|
|
|
|
|
.as_mut()
|
|
|
|
|
.is_some_and(|monitor| !monitor.is_running())
|
|
|
|
|
{
|
|
|
|
|
self.microphone = None;
|
|
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
if self
|
|
|
|
|
.microphone_probe
|
|
|
|
|
.as_mut()
|
|
|
|
|
.is_some_and(|probe| !probe.is_running())
|
|
|
|
|
{
|
|
|
|
|
self.microphone_probe = None;
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
for kind in [DeviceTestKind::MicrophoneReplay, DeviceTestKind::Speaker] {
|
2026-04-14 23:03:18 -03:00
|
|
|
let finished = self
|
|
|
|
|
.slot_mut(kind)
|
|
|
|
|
.as_mut()
|
|
|
|
|
.and_then(|child| child.try_wait().ok())
|
|
|
|
|
.flatten()
|
|
|
|
|
.is_some();
|
|
|
|
|
if finished {
|
|
|
|
|
let _ = self.slot_mut(kind).take();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn slot(&self, kind: DeviceTestKind) -> &Option<Child> {
|
|
|
|
|
match kind {
|
2026-04-16 12:58:05 -03:00
|
|
|
DeviceTestKind::Camera | DeviceTestKind::Microphone => {
|
|
|
|
|
panic!("this device test is not an external child process")
|
|
|
|
|
}
|
|
|
|
|
DeviceTestKind::MicrophoneReplay => &self.microphone_replay,
|
2026-04-14 23:03:18 -03:00
|
|
|
DeviceTestKind::Speaker => &self.speaker,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn slot_mut(&mut self, kind: DeviceTestKind) -> &mut Option<Child> {
|
|
|
|
|
match kind {
|
2026-04-16 12:58:05 -03:00
|
|
|
DeviceTestKind::Camera | DeviceTestKind::Microphone => {
|
|
|
|
|
panic!("this device test is not an external child process")
|
|
|
|
|
}
|
|
|
|
|
DeviceTestKind::MicrophoneReplay => &mut self.microphone_replay,
|
2026-04-14 23:03:18 -03:00
|
|
|
DeviceTestKind::Speaker => &mut self.speaker,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
fn stop_microphone_probe(&mut self) {
|
|
|
|
|
if let Some(mut probe) = self.microphone_probe.take() {
|
|
|
|
|
probe.stop();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn replay_wav_bytes(&self) -> Result<Vec<u8>> {
|
|
|
|
|
let audio = self
|
|
|
|
|
.microphone_buffer
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|_| anyhow!("microphone replay buffer is unavailable right now"))?
|
|
|
|
|
.clone();
|
|
|
|
|
if audio.is_empty() {
|
|
|
|
|
return Err(anyhow!(
|
|
|
|
|
"Monitor Mic long enough to capture audio before replaying the last 3 seconds."
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(build_wav_bytes(
|
|
|
|
|
&audio,
|
|
|
|
|
MIC_MONITOR_RATE as u32,
|
|
|
|
|
MIC_MONITOR_CHANNELS as u16,
|
|
|
|
|
16,
|
|
|
|
|
))
|
|
|
|
|
}
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
struct LocalCameraPreview {
|
|
|
|
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
|
|
|
|
status_text: Arc<Mutex<String>>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
selected_device: Option<String>,
|
2026-04-22 22:10:39 -03:00
|
|
|
selected_mode: Option<CameraMode>,
|
2026-04-22 05:46:31 -03:00
|
|
|
relay_preview_path: Option<PathBuf>,
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
struct LocalMicrophoneMonitor {
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
struct LocalMicrophoneLevelProbe {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
struct PreviewFrame {
|
|
|
|
|
width: i32,
|
|
|
|
|
height: i32,
|
|
|
|
|
stride: usize,
|
|
|
|
|
rgba: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl LocalCameraPreview {
|
|
|
|
|
fn new(picture: >k::Picture, status_label: >k::Label) -> Self {
|
|
|
|
|
let latest = Arc::new(Mutex::new(None::<PreviewFrame>));
|
|
|
|
|
let status_text = Arc::new(Mutex::new(CAMERA_PREVIEW_IDLE.to_string()));
|
|
|
|
|
let generation = Arc::new(AtomicU64::new(0));
|
|
|
|
|
let running = Arc::new(AtomicBool::new(false));
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
picture.set_paintable(Some(&blank_camera_preview_texture()));
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
{
|
|
|
|
|
let picture = picture.clone();
|
|
|
|
|
let status_label = status_label.clone();
|
|
|
|
|
let latest = Arc::clone(&latest);
|
|
|
|
|
let status_text = Arc::clone(&status_text);
|
|
|
|
|
glib::timeout_add_local(Duration::from_millis(120), move || {
|
|
|
|
|
let next = latest.lock().ok().and_then(|mut slot| slot.take());
|
|
|
|
|
if let Some(frame) = next {
|
|
|
|
|
let bytes = glib::Bytes::from_owned(frame.rgba);
|
|
|
|
|
let texture = gdk::MemoryTexture::new(
|
|
|
|
|
frame.width,
|
|
|
|
|
frame.height,
|
|
|
|
|
gdk::MemoryFormat::R8g8b8a8,
|
|
|
|
|
&bytes,
|
|
|
|
|
frame.stride,
|
|
|
|
|
);
|
|
|
|
|
picture.set_paintable(Some(&texture));
|
|
|
|
|
}
|
|
|
|
|
if let Ok(text) = status_text.lock() {
|
|
|
|
|
status_label.set_text(text.as_str());
|
|
|
|
|
}
|
|
|
|
|
glib::ControlFlow::Continue
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Self {
|
|
|
|
|
latest,
|
|
|
|
|
status_text,
|
|
|
|
|
generation,
|
|
|
|
|
running,
|
|
|
|
|
selected_device: None,
|
2026-04-22 22:10:39 -03:00
|
|
|
selected_mode: None,
|
2026-04-22 05:46:31 -03:00
|
|
|
relay_preview_path: None,
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_running(&self) -> bool {
|
|
|
|
|
self.running.load(Ordering::Acquire)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
fn is_device_preview_running(&self) -> bool {
|
|
|
|
|
self.is_running() && self.relay_preview_path.is_none()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_relay_file_running(&self) -> bool {
|
|
|
|
|
self.is_running() && self.relay_preview_path.is_some()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn set_selected(&mut self, camera: Option<&str>) -> Result<()> {
|
|
|
|
|
self.selected_device = normalize_camera_selection(camera);
|
|
|
|
|
|
|
|
|
|
if self.is_running() {
|
|
|
|
|
self.stop();
|
|
|
|
|
self.start()?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
self.set_status(match self.selected_device.as_deref() {
|
2026-04-15 01:57:14 -03:00
|
|
|
Some(camera) => format!(
|
2026-04-20 11:11:51 -03:00
|
|
|
"Selected {camera}. Start Preview to confirm webcam framing here before you launch the relay."
|
2026-04-15 01:57:14 -03:00
|
|
|
),
|
2026-04-15 01:20:51 -03:00
|
|
|
None => CAMERA_PREVIEW_IDLE.to_string(),
|
|
|
|
|
});
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
fn set_selected_mode(&mut self, mode: Option<CameraMode>) -> Result<()> {
|
|
|
|
|
self.selected_mode = mode;
|
|
|
|
|
|
|
|
|
|
if self.is_device_preview_running() {
|
|
|
|
|
self.stop();
|
|
|
|
|
self.start()?;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let Some(camera) = self.selected_device.as_deref() {
|
|
|
|
|
let quality = self
|
|
|
|
|
.selected_mode
|
|
|
|
|
.map(CameraMode::short_label)
|
|
|
|
|
.unwrap_or_else(|| "default quality".to_string());
|
|
|
|
|
self.set_status(format!(
|
|
|
|
|
"Selected {camera} at {quality}. Start Preview to confirm webcam framing here before you launch the relay."
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn toggle(&mut self) -> Result<bool> {
|
|
|
|
|
if self.is_running() {
|
|
|
|
|
self.stop();
|
|
|
|
|
return Ok(false);
|
|
|
|
|
}
|
|
|
|
|
self.start()?;
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn start(&mut self) -> Result<()> {
|
|
|
|
|
gst::init().context("initialising in-launcher camera preview")?;
|
|
|
|
|
let selected = self
|
|
|
|
|
.selected_device
|
|
|
|
|
.clone()
|
|
|
|
|
.ok_or_else(|| anyhow!("select a camera before starting the in-launcher preview"))?;
|
2026-04-22 05:46:31 -03:00
|
|
|
self.relay_preview_path = None;
|
2026-04-22 22:10:39 -03:00
|
|
|
let mode = self.selected_mode;
|
2026-04-15 01:20:51 -03:00
|
|
|
let device = resolve_camera_device(&selected);
|
|
|
|
|
let latest = Arc::clone(&self.latest);
|
|
|
|
|
let status_text = Arc::clone(&self.status_text);
|
|
|
|
|
let generation = Arc::clone(&self.generation);
|
|
|
|
|
let running = Arc::clone(&self.running);
|
|
|
|
|
let token = generation.fetch_add(1, Ordering::AcqRel) + 1;
|
|
|
|
|
running.store(true, Ordering::Release);
|
2026-04-15 01:57:14 -03:00
|
|
|
self.set_status(format!("Starting local preview for {selected}..."));
|
2026-04-15 01:20:51 -03:00
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
if let Err(err) = run_camera_preview_feed(
|
|
|
|
|
selected,
|
|
|
|
|
device,
|
2026-04-22 22:10:39 -03:00
|
|
|
mode,
|
2026-04-15 01:20:51 -03:00
|
|
|
token,
|
|
|
|
|
latest,
|
|
|
|
|
status_text.clone(),
|
|
|
|
|
generation.clone(),
|
|
|
|
|
running.clone(),
|
2026-04-15 01:57:14 -03:00
|
|
|
) && generation.load(Ordering::Acquire) == token
|
|
|
|
|
{
|
|
|
|
|
running.store(false, Ordering::Release);
|
|
|
|
|
if let Ok(mut status) = status_text.lock() {
|
|
|
|
|
*status = format!("Camera preview failed: {err}");
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
fn start_relay_file(&mut self, path: PathBuf, selected: String) -> Result<()> {
|
|
|
|
|
if self.is_running()
|
|
|
|
|
&& self
|
|
|
|
|
.relay_preview_path
|
|
|
|
|
.as_ref()
|
|
|
|
|
.is_some_and(|existing| existing == &path)
|
|
|
|
|
{
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
if self.is_running() {
|
|
|
|
|
self.stop();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let latest = Arc::clone(&self.latest);
|
|
|
|
|
let status_text = Arc::clone(&self.status_text);
|
|
|
|
|
let generation = Arc::clone(&self.generation);
|
|
|
|
|
let running = Arc::clone(&self.running);
|
|
|
|
|
let token = generation.fetch_add(1, Ordering::AcqRel) + 1;
|
|
|
|
|
running.store(true, Ordering::Release);
|
|
|
|
|
self.relay_preview_path = Some(path.clone());
|
|
|
|
|
self.set_status(format!(
|
|
|
|
|
"Waiting for relay webcam frames from {selected}..."
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
run_camera_file_preview_feed(
|
|
|
|
|
path,
|
|
|
|
|
selected,
|
|
|
|
|
token,
|
|
|
|
|
latest,
|
|
|
|
|
status_text,
|
|
|
|
|
generation,
|
|
|
|
|
running,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn stop(&mut self) {
|
2026-04-22 05:46:31 -03:00
|
|
|
let was_relay_file = self.relay_preview_path.take().is_some();
|
2026-04-15 01:20:51 -03:00
|
|
|
self.running.store(false, Ordering::Release);
|
|
|
|
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
|
|
|
|
if let Ok(mut latest) = self.latest.lock() {
|
|
|
|
|
*latest = None;
|
|
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
let message = if was_relay_file {
|
|
|
|
|
"Relay webcam preview stopped.".to_string()
|
|
|
|
|
} else {
|
|
|
|
|
match self.selected_device.as_deref() {
|
|
|
|
|
Some(camera) => {
|
2026-04-22 22:10:39 -03:00
|
|
|
let quality = self
|
|
|
|
|
.selected_mode
|
|
|
|
|
.map(CameraMode::short_label)
|
|
|
|
|
.unwrap_or_else(|| "default quality".to_string());
|
2026-04-22 05:46:31 -03:00
|
|
|
format!(
|
2026-04-22 22:10:39 -03:00
|
|
|
"Local preview stopped. {camera} at {quality} stays selected for the next relay launch."
|
2026-04-22 05:46:31 -03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
None => CAMERA_PREVIEW_IDLE.to_string(),
|
2026-04-15 01:57:14 -03:00
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
};
|
|
|
|
|
self.set_status(message);
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_status(&self, text: String) {
|
|
|
|
|
if let Ok(mut status) = self.status_text.lock() {
|
|
|
|
|
*status = text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn blank_camera_preview_texture() -> gdk::MemoryTexture {
|
2026-04-23 04:46:21 -03:00
|
|
|
let rgba =
|
|
|
|
|
vec![12_u8; (CAMERA_PREVIEW_DEFAULT_WIDTH * CAMERA_PREVIEW_DEFAULT_HEIGHT * 4) as usize];
|
2026-04-16 12:58:05 -03:00
|
|
|
let bytes = glib::Bytes::from_owned(rgba);
|
|
|
|
|
gdk::MemoryTexture::new(
|
2026-04-23 04:46:21 -03:00
|
|
|
CAMERA_PREVIEW_DEFAULT_WIDTH,
|
|
|
|
|
CAMERA_PREVIEW_DEFAULT_HEIGHT,
|
2026-04-16 12:58:05 -03:00
|
|
|
gdk::MemoryFormat::R8g8b8a8,
|
|
|
|
|
&bytes,
|
2026-04-23 04:46:21 -03:00
|
|
|
(CAMERA_PREVIEW_DEFAULT_WIDTH * 4) as usize,
|
2026-04-16 12:58:05 -03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl LocalMicrophoneMonitor {
|
|
|
|
|
fn start(
|
|
|
|
|
source: Option<&str>,
|
|
|
|
|
sink: Option<&str>,
|
|
|
|
|
recent_audio: Arc<Mutex<Vec<u8>>>,
|
|
|
|
|
level: Arc<Mutex<f64>>,
|
|
|
|
|
) -> Result<Self> {
|
|
|
|
|
gst::init().context("initialising microphone preview")?;
|
|
|
|
|
let source = source
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.ok_or_else(|| anyhow!("select a microphone before starting Monitor Mic"))?
|
|
|
|
|
.to_string();
|
|
|
|
|
let sink = sink
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.map(ToOwned::to_owned);
|
|
|
|
|
if let Ok(mut buffer) = recent_audio.lock() {
|
|
|
|
|
buffer.clear();
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = 0.0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
|
|
|
let generation = Arc::new(AtomicU64::new(1));
|
|
|
|
|
let running_handle = Arc::clone(&running);
|
|
|
|
|
let generation_handle = Arc::clone(&generation);
|
|
|
|
|
let token = generation.load(Ordering::Acquire);
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
let _ = run_microphone_monitor_feed(
|
|
|
|
|
&source,
|
|
|
|
|
sink.as_deref(),
|
|
|
|
|
token,
|
|
|
|
|
recent_audio,
|
|
|
|
|
level,
|
|
|
|
|
generation_handle,
|
|
|
|
|
running_handle,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
running,
|
|
|
|
|
generation,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_running(&self) -> bool {
|
|
|
|
|
self.running.load(Ordering::Acquire)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stop(&mut self) {
|
|
|
|
|
self.running.store(false, Ordering::Release);
|
|
|
|
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
impl LocalMicrophoneLevelProbe {
|
|
|
|
|
fn start(path: PathBuf, level: Arc<Mutex<f64>>) -> Self {
|
|
|
|
|
let running = Arc::new(AtomicBool::new(true));
|
|
|
|
|
let generation = Arc::new(AtomicU64::new(1));
|
|
|
|
|
let running_handle = Arc::clone(&running);
|
|
|
|
|
let generation_handle = Arc::clone(&generation);
|
|
|
|
|
let path_handle = path.clone();
|
|
|
|
|
let token = generation.load(Ordering::Acquire);
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
run_microphone_level_probe(
|
|
|
|
|
path_handle,
|
|
|
|
|
token,
|
|
|
|
|
level,
|
|
|
|
|
generation_handle,
|
|
|
|
|
running_handle,
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
Self {
|
|
|
|
|
path,
|
|
|
|
|
running,
|
|
|
|
|
generation,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_running(&self) -> bool {
|
|
|
|
|
self.running.load(Ordering::Acquire)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn is_running_for(&self, path: &Path) -> bool {
|
|
|
|
|
self.is_running() && self.path == path
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stop(&mut self) {
|
|
|
|
|
self.running.store(false, Ordering::Release);
|
|
|
|
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn normalize_camera_selection(camera: Option<&str>) -> Option<String> {
|
|
|
|
|
camera
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("auto"))
|
|
|
|
|
.map(ToOwned::to_owned)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_camera_device(camera: &str) -> String {
|
|
|
|
|
if camera.starts_with("/dev/") {
|
|
|
|
|
camera.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("/dev/v4l/by-id/{camera}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn run_microphone_monitor_feed(
|
|
|
|
|
source: &str,
|
|
|
|
|
sink: Option<&str>,
|
|
|
|
|
token: u64,
|
|
|
|
|
recent_audio: Arc<Mutex<Vec<u8>>>,
|
|
|
|
|
level: Arc<Mutex<f64>>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let (pipeline, appsink) = build_microphone_monitor_pipeline(source, sink)?;
|
|
|
|
|
pipeline
|
|
|
|
|
.set_state(gst::State::Playing)
|
|
|
|
|
.context("starting microphone preview pipeline")?;
|
|
|
|
|
|
|
|
|
|
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
|
|
|
|
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250)) {
|
|
|
|
|
if let Some(buffer) = sample.buffer()
|
|
|
|
|
&& let Ok(map) = buffer.map_readable()
|
|
|
|
|
{
|
|
|
|
|
let bytes = map.as_slice();
|
|
|
|
|
push_recent_audio(&recent_audio, bytes);
|
|
|
|
|
update_microphone_level(&level, bytes);
|
|
|
|
|
}
|
|
|
|
|
} else if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = (*meter * 0.8).clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = pipeline.set_state(gst::State::Null);
|
|
|
|
|
if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = 0.0;
|
|
|
|
|
}
|
|
|
|
|
running.store(false, Ordering::Release);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn run_camera_preview_feed(
|
|
|
|
|
selected: String,
|
|
|
|
|
device: String,
|
2026-04-22 22:10:39 -03:00
|
|
|
mode: Option<CameraMode>,
|
2026-04-15 01:20:51 -03:00
|
|
|
token: u64,
|
|
|
|
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
|
|
|
|
status_text: Arc<Mutex<String>>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
) -> Result<()> {
|
2026-04-22 22:10:39 -03:00
|
|
|
let (pipeline, appsink) = build_camera_preview_pipeline(&device, mode)?;
|
2026-04-15 01:20:51 -03:00
|
|
|
pipeline
|
|
|
|
|
.set_state(gst::State::Playing)
|
|
|
|
|
.context("starting in-launcher camera preview pipeline")?;
|
|
|
|
|
|
|
|
|
|
if let Ok(mut status) = status_text.lock() {
|
2026-04-22 22:10:39 -03:00
|
|
|
let quality = mode
|
|
|
|
|
.map(CameraMode::short_label)
|
|
|
|
|
.unwrap_or_else(|| "default quality".to_string());
|
2026-04-23 04:46:21 -03:00
|
|
|
*status = format!("Local preview live for {selected} at {quality}; waiting for frames...");
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:46:21 -03:00
|
|
|
let mut announced_size = None::<(i32, i32)>;
|
2026-04-15 01:20:51 -03:00
|
|
|
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
2026-04-15 01:57:14 -03:00
|
|
|
if let Some(sample) = appsink.try_pull_sample(gst::ClockTime::from_mseconds(250))
|
|
|
|
|
&& let Some(frame) = sample_to_frame(&sample)
|
|
|
|
|
{
|
2026-04-23 04:46:21 -03:00
|
|
|
let size = (frame.width, frame.height);
|
|
|
|
|
if announced_size != Some(size) {
|
|
|
|
|
announced_size = Some(size);
|
|
|
|
|
if let Ok(mut status) = status_text.lock() {
|
|
|
|
|
let quality = mode
|
|
|
|
|
.map(CameraMode::short_label)
|
|
|
|
|
.unwrap_or_else(|| "default quality".to_string());
|
|
|
|
|
*status = format!(
|
|
|
|
|
"Local preview live for {selected} at {quality}; showing {}x{}.",
|
|
|
|
|
size.0, size.1
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mut slot) = latest.lock() {
|
|
|
|
|
*slot = Some(frame);
|
|
|
|
|
}
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = pipeline.set_state(gst::State::Null);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
fn run_camera_file_preview_feed(
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
selected: String,
|
|
|
|
|
token: u64,
|
|
|
|
|
latest: Arc<Mutex<Option<PreviewFrame>>>,
|
|
|
|
|
status_text: Arc<Mutex<String>>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
let mut has_frame = false;
|
2026-04-23 04:46:21 -03:00
|
|
|
let mut announced_size = None::<(i32, i32)>;
|
2026-04-22 05:46:31 -03:00
|
|
|
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
|
|
|
|
match read_camera_preview_tap(&path) {
|
|
|
|
|
Ok(frame) => {
|
2026-04-23 04:46:21 -03:00
|
|
|
let size = (frame.width, frame.height);
|
2026-04-22 05:46:31 -03:00
|
|
|
if let Ok(mut slot) = latest.lock() {
|
|
|
|
|
*slot = Some(frame);
|
|
|
|
|
}
|
2026-04-23 04:46:21 -03:00
|
|
|
if !has_frame || announced_size != Some(size) {
|
2026-04-22 05:46:31 -03:00
|
|
|
has_frame = true;
|
2026-04-23 04:46:21 -03:00
|
|
|
announced_size = Some(size);
|
2026-04-22 05:46:31 -03:00
|
|
|
if let Ok(mut status) = status_text.lock() {
|
2026-04-23 04:46:21 -03:00
|
|
|
*status = format!(
|
|
|
|
|
"Relay webcam preview live for {selected}; showing {}x{}.",
|
|
|
|
|
size.0, size.1
|
|
|
|
|
);
|
2026-04-22 05:46:31 -03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(err) => {
|
|
|
|
|
if !has_frame && let Ok(mut status) = status_text.lock() {
|
|
|
|
|
*status = format!("Waiting for relay webcam frames from {selected}: {err}");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(120));
|
|
|
|
|
}
|
|
|
|
|
running.store(false, Ordering::Release);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn run_microphone_level_probe(
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
token: u64,
|
|
|
|
|
level: Arc<Mutex<f64>>,
|
|
|
|
|
generation: Arc<AtomicU64>,
|
|
|
|
|
running: Arc<AtomicBool>,
|
|
|
|
|
) {
|
|
|
|
|
while running.load(Ordering::Acquire) && generation.load(Ordering::Acquire) == token {
|
|
|
|
|
let next = read_microphone_level_tap(&path).unwrap_or_else(|| {
|
|
|
|
|
level
|
|
|
|
|
.lock()
|
|
|
|
|
.map(|current| (*current * 0.8).clamp(0.0, 1.0))
|
|
|
|
|
.unwrap_or(0.0)
|
|
|
|
|
});
|
|
|
|
|
if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = next.clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
|
|
|
}
|
|
|
|
|
if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = 0.0;
|
|
|
|
|
}
|
|
|
|
|
running.store(false, Ordering::Release);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
fn build_camera_preview_pipeline(
|
|
|
|
|
device: &str,
|
|
|
|
|
mode: Option<CameraMode>,
|
|
|
|
|
) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
|
|
|
|
let desc = camera_preview_pipeline_desc(device, mode);
|
2026-04-23 04:46:21 -03:00
|
|
|
let (width, height, _fps) = camera_preview_mode(mode);
|
2026-04-15 01:20:51 -03:00
|
|
|
let pipeline = gst::parse::launch(&desc)?
|
|
|
|
|
.downcast::<gst::Pipeline>()
|
|
|
|
|
.expect("camera preview pipeline");
|
|
|
|
|
let appsink = pipeline
|
|
|
|
|
.by_name("sink")
|
|
|
|
|
.context("missing in-launcher camera preview appsink")?
|
|
|
|
|
.downcast::<gst_app::AppSink>()
|
|
|
|
|
.expect("camera preview appsink");
|
|
|
|
|
appsink.set_caps(Some(
|
|
|
|
|
&gst::Caps::builder("video/x-raw")
|
2026-04-15 01:57:14 -03:00
|
|
|
.field("format", "RGBA")
|
2026-04-23 04:46:21 -03:00
|
|
|
.field("width", width)
|
|
|
|
|
.field("height", height)
|
2026-04-15 01:20:51 -03:00
|
|
|
.build(),
|
|
|
|
|
));
|
|
|
|
|
Ok((pipeline, appsink))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn build_microphone_monitor_pipeline(
|
|
|
|
|
source: &str,
|
|
|
|
|
sink: Option<&str>,
|
|
|
|
|
) -> Result<(gst::Pipeline, gst_app::AppSink)> {
|
|
|
|
|
let desc = microphone_monitor_pipeline_desc(source, sink);
|
|
|
|
|
let pipeline = gst::parse::launch(&desc)?
|
|
|
|
|
.downcast::<gst::Pipeline>()
|
|
|
|
|
.expect("microphone monitor pipeline");
|
|
|
|
|
let appsink = pipeline
|
|
|
|
|
.by_name("mic_preview_sink")
|
|
|
|
|
.context("missing microphone preview appsink")?
|
|
|
|
|
.downcast::<gst_app::AppSink>()
|
|
|
|
|
.expect("microphone preview appsink");
|
|
|
|
|
appsink.set_caps(Some(
|
|
|
|
|
&gst::Caps::builder("audio/x-raw")
|
|
|
|
|
.field("format", "S16LE")
|
|
|
|
|
.field("rate", MIC_MONITOR_RATE)
|
|
|
|
|
.field("channels", MIC_MONITOR_CHANNELS)
|
|
|
|
|
.build(),
|
|
|
|
|
));
|
|
|
|
|
Ok((pipeline, appsink))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
fn camera_preview_pipeline_desc(device: &str, mode: Option<CameraMode>) -> String {
|
2026-04-15 02:46:59 -03:00
|
|
|
let device = gst_quote(device);
|
2026-04-23 04:46:21 -03:00
|
|
|
let (preview_width, preview_height, preview_fps) = camera_preview_mode(mode);
|
2026-04-22 22:10:39 -03:00
|
|
|
let source_caps = mode
|
|
|
|
|
.map(|mode| {
|
|
|
|
|
format!(
|
|
|
|
|
"capsfilter caps=\"video/x-raw,width=(int){},height=(int){},framerate=(fraction){}/1;image/jpeg,width=(int){},height=(int){},framerate=(fraction){}/1\" ! decodebin ! ",
|
|
|
|
|
mode.width, mode.height, mode.fps, mode.width, mode.height, mode.fps
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or_default();
|
2026-04-15 02:46:59 -03:00
|
|
|
format!(
|
|
|
|
|
"v4l2src device=\"{device}\" do-timestamp=true ! \
|
2026-04-22 22:10:39 -03:00
|
|
|
{source_caps}videoconvert ! videoscale ! videorate ! \
|
2026-04-23 04:46:21 -03:00
|
|
|
video/x-raw,format=RGBA,width={preview_width},height={preview_height},framerate={preview_fps}/1,pixel-aspect-ratio=1/1 ! \
|
2026-04-15 02:46:59 -03:00
|
|
|
appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:46:21 -03:00
|
|
|
fn camera_preview_mode(mode: Option<CameraMode>) -> (i32, i32, u32) {
|
|
|
|
|
mode.map(|mode| {
|
|
|
|
|
(
|
|
|
|
|
i32::try_from(mode.width).unwrap_or(i32::MAX).max(1),
|
|
|
|
|
i32::try_from(mode.height).unwrap_or(i32::MAX).max(1),
|
|
|
|
|
mode.fps.max(1),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.unwrap_or((
|
|
|
|
|
CAMERA_PREVIEW_DEFAULT_WIDTH,
|
|
|
|
|
CAMERA_PREVIEW_DEFAULT_HEIGHT,
|
|
|
|
|
CAMERA_PREVIEW_DEFAULT_FPS,
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn microphone_monitor_pipeline_desc(source: &str, sink: Option<&str>) -> String {
|
2026-04-22 08:07:09 -03:00
|
|
|
let source_element = if looks_like_pulse_source_name(source)
|
|
|
|
|
|| gst::ElementFactory::find("pipewiresrc").is_none()
|
|
|
|
|
{
|
2026-04-20 12:12:29 -03:00
|
|
|
let source = gst_quote(source);
|
2026-04-22 08:07:09 -03:00
|
|
|
format!("pulsesrc device=\"{source}\" do-timestamp=true")
|
2026-04-20 12:12:29 -03:00
|
|
|
} else {
|
|
|
|
|
let source = gst_quote(source);
|
2026-04-22 08:07:09 -03:00
|
|
|
format!("pipewiresrc target-object=\"{source}\" do-timestamp=true")
|
2026-04-20 12:12:29 -03:00
|
|
|
};
|
2026-04-16 12:58:05 -03:00
|
|
|
let sink_prop = sink
|
|
|
|
|
.map(gst_quote)
|
|
|
|
|
.map(|value| format!(" device=\"{value}\""))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
format!(
|
2026-04-20 12:12:29 -03:00
|
|
|
"{source_element} ! \
|
2026-04-16 12:58:05 -03:00
|
|
|
audioconvert ! audioresample ! \
|
|
|
|
|
audio/x-raw,format=S16LE,rate={MIC_MONITOR_RATE},channels={MIC_MONITOR_CHANNELS} ! \
|
|
|
|
|
tee name=t \
|
|
|
|
|
t. ! queue ! pulsesink{sink_prop} \
|
|
|
|
|
t. ! queue ! appsink name=mic_preview_sink emit-signals=false sync=false max-buffers=8 drop=true"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 08:07:09 -03:00
|
|
|
fn looks_like_pulse_source_name(source: &str) -> bool {
|
|
|
|
|
let source = source.trim();
|
|
|
|
|
source.starts_with("alsa_input.")
|
|
|
|
|
|| source.starts_with("bluez_input.")
|
|
|
|
|
|| source.starts_with("input.")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn sample_to_frame(sample: &gst::Sample) -> Option<PreviewFrame> {
|
|
|
|
|
let caps = sample.caps()?;
|
|
|
|
|
let structure = caps.structure(0)?;
|
|
|
|
|
let width = structure.get::<i32>("width").ok()?;
|
|
|
|
|
let height = structure.get::<i32>("height").ok()?;
|
|
|
|
|
let buffer = sample.buffer()?;
|
|
|
|
|
let map = buffer.map_readable().ok()?;
|
|
|
|
|
let rgba = map.as_slice().to_vec();
|
|
|
|
|
let stride = rgba.len() / height.max(1) as usize;
|
|
|
|
|
Some(PreviewFrame {
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
stride,
|
|
|
|
|
rgba,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 05:46:31 -03:00
|
|
|
fn read_camera_preview_tap(path: &Path) -> Result<PreviewFrame> {
|
|
|
|
|
let bytes = fs::read(path).with_context(|| format!("{} is not ready", path.display()))?;
|
|
|
|
|
let header_end = bytes
|
|
|
|
|
.iter()
|
|
|
|
|
.position(|byte| *byte == b'\n')
|
|
|
|
|
.ok_or_else(|| anyhow!("preview frame header is incomplete"))?;
|
|
|
|
|
let header =
|
|
|
|
|
std::str::from_utf8(&bytes[..header_end]).context("preview frame header is not UTF-8")?;
|
|
|
|
|
let mut fields = header.split_whitespace();
|
|
|
|
|
if fields.next() != Some("LESAVKA_RGBA") {
|
|
|
|
|
return Err(anyhow!("preview frame has an unknown format"));
|
|
|
|
|
}
|
|
|
|
|
let width = fields
|
|
|
|
|
.next()
|
|
|
|
|
.and_then(|value| value.parse::<i32>().ok())
|
|
|
|
|
.filter(|value| *value > 0)
|
|
|
|
|
.ok_or_else(|| anyhow!("preview frame width is invalid"))?;
|
|
|
|
|
let height = fields
|
|
|
|
|
.next()
|
|
|
|
|
.and_then(|value| value.parse::<i32>().ok())
|
|
|
|
|
.filter(|value| *value > 0)
|
|
|
|
|
.ok_or_else(|| anyhow!("preview frame height is invalid"))?;
|
|
|
|
|
let stride = fields
|
|
|
|
|
.next()
|
|
|
|
|
.and_then(|value| value.parse::<usize>().ok())
|
|
|
|
|
.filter(|value| *value > 0)
|
|
|
|
|
.ok_or_else(|| anyhow!("preview frame stride is invalid"))?;
|
|
|
|
|
let rgba = bytes[header_end + 1..].to_vec();
|
|
|
|
|
let expected_min = stride.saturating_mul(height as usize);
|
|
|
|
|
if rgba.len() < expected_min {
|
|
|
|
|
return Err(anyhow!("preview frame payload is incomplete"));
|
|
|
|
|
}
|
|
|
|
|
Ok(PreviewFrame {
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
stride,
|
|
|
|
|
rgba,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_microphone_level_tap(path: &Path) -> Option<f64> {
|
|
|
|
|
fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|raw| raw.split_ascii_whitespace().next()?.parse::<f64>().ok())
|
|
|
|
|
.filter(|value| value.is_finite())
|
|
|
|
|
.map(|value| value.clamp(0.0, 1.0))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
fn gst_quote(value: &str) -> String {
|
|
|
|
|
value.replace('\\', "\\\\").replace('"', "\\\"")
|
2026-04-14 23:03:18 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn build_speaker_test(sink: Option<&str>) -> Result<Command> {
|
2026-04-14 23:03:18 -03:00
|
|
|
let sink_prop = sink
|
2026-04-16 12:58:05 -03:00
|
|
|
.filter(|value| !value.trim().is_empty())
|
2026-04-14 23:03:18 -03:00
|
|
|
.map(|value| format!("device={}", quote(value)))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
Ok(shell_command(format!(
|
2026-04-16 12:58:05 -03:00
|
|
|
"gst-launch-1.0 -q audiotestsrc is-live=true wave=sine freq=880 volume=0.25 ! audioconvert ! audioresample ! queue ! pulsesink {}",
|
2026-04-14 23:03:18 -03:00
|
|
|
sink_prop
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn build_microphone_replay_test(path: &str, sink: Option<&str>) -> Result<Command> {
|
2026-04-14 23:03:18 -03:00
|
|
|
let sink_prop = sink
|
|
|
|
|
.filter(|value| !value.trim().is_empty())
|
|
|
|
|
.map(|value| format!("device={}", quote(value)))
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
Ok(shell_command(format!(
|
2026-04-16 12:58:05 -03:00
|
|
|
"gst-launch-1.0 -q filesrc location={} ! wavparse ! audioconvert ! audioresample ! queue ! pulsesink {}",
|
|
|
|
|
quote(path),
|
2026-04-14 23:03:18 -03:00
|
|
|
sink_prop
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shell_command(command: String) -> Command {
|
|
|
|
|
let mut child = Command::new("bash");
|
|
|
|
|
child.args(["-lc", &command]);
|
|
|
|
|
child
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn quote(value: impl Into<String>) -> String {
|
|
|
|
|
escape(Cow::Owned(value.into())).into_owned()
|
|
|
|
|
}
|
2026-04-15 01:20:51 -03:00
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
fn push_recent_audio(buffer: &Arc<Mutex<Vec<u8>>>, bytes: &[u8]) {
|
|
|
|
|
if let Ok(mut ring) = buffer.lock() {
|
|
|
|
|
ring.extend_from_slice(bytes);
|
|
|
|
|
if ring.len() > MIC_REPLAY_MAX_BYTES {
|
|
|
|
|
let overflow = ring.len() - MIC_REPLAY_MAX_BYTES;
|
|
|
|
|
ring.drain(0..overflow);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn update_microphone_level(level: &Arc<Mutex<f64>>, bytes: &[u8]) {
|
|
|
|
|
let peak = bytes
|
|
|
|
|
.chunks_exact(2)
|
|
|
|
|
.map(|chunk| i16::from_le_bytes([chunk[0], chunk[1]]).unsigned_abs() as f64)
|
|
|
|
|
.fold(0.0, f64::max)
|
|
|
|
|
/ i16::MAX as f64;
|
|
|
|
|
if let Ok(mut meter) = level.lock() {
|
|
|
|
|
*meter = peak.clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_wav_bytes(audio: &[u8], sample_rate: u32, channels: u16, bits_per_sample: u16) -> Vec<u8> {
|
|
|
|
|
let block_align = channels * (bits_per_sample / 8);
|
|
|
|
|
let byte_rate = sample_rate * block_align as u32;
|
|
|
|
|
let data_len = audio.len() as u32;
|
|
|
|
|
let riff_len = 36 + data_len;
|
|
|
|
|
|
|
|
|
|
let mut wav = Vec::with_capacity(44 + audio.len());
|
|
|
|
|
wav.extend_from_slice(b"RIFF");
|
|
|
|
|
wav.extend_from_slice(&riff_len.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(b"WAVE");
|
|
|
|
|
wav.extend_from_slice(b"fmt ");
|
|
|
|
|
wav.extend_from_slice(&16u32.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&1u16.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&channels.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&sample_rate.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&byte_rate.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&block_align.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(&bits_per_sample.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(b"data");
|
|
|
|
|
wav.extend_from_slice(&data_len.to_le_bytes());
|
|
|
|
|
wav.extend_from_slice(audio);
|
|
|
|
|
wav
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 01:20:51 -03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
2026-04-16 12:58:05 -03:00
|
|
|
use super::{
|
2026-04-23 04:46:21 -03:00
|
|
|
MIC_REPLAY_MAX_BYTES, build_wav_bytes, camera_preview_mode, camera_preview_pipeline_desc,
|
2026-04-22 08:07:09 -03:00
|
|
|
microphone_monitor_pipeline_desc, normalize_camera_selection, push_recent_audio,
|
|
|
|
|
read_camera_preview_tap, read_microphone_level_tap, resolve_camera_device,
|
2026-04-16 12:58:05 -03:00
|
|
|
};
|
2026-04-22 22:10:39 -03:00
|
|
|
use crate::launcher::devices::CameraMode;
|
2026-04-16 12:58:05 -03:00
|
|
|
use std::sync::{Arc, Mutex};
|
2026-04-15 01:20:51 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_camera_device_accepts_explicit_paths_and_catalog_names() {
|
|
|
|
|
assert_eq!(resolve_camera_device("/dev/video0"), "/dev/video0");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_camera_device("usb-Logitech_C920-video-index0"),
|
|
|
|
|
"/dev/v4l/by-id/usb-Logitech_C920-video-index0"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn normalize_camera_selection_drops_auto_and_blank_values() {
|
|
|
|
|
assert_eq!(normalize_camera_selection(None), None);
|
|
|
|
|
assert_eq!(normalize_camera_selection(Some("")), None);
|
|
|
|
|
assert_eq!(normalize_camera_selection(Some("auto")), None);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_camera_selection(Some("usb-Logitech_C920-video-index0")),
|
|
|
|
|
Some("usb-Logitech_C920-video-index0".to_string())
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-15 02:46:59 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn camera_preview_pipeline_scales_after_source_instead_of_pinning_raw_source_caps() {
|
2026-04-22 22:10:39 -03:00
|
|
|
let desc = camera_preview_pipeline_desc("/dev/video0", None);
|
2026-04-15 02:46:59 -03:00
|
|
|
assert!(desc.contains("v4l2src device=\"/dev/video0\""));
|
|
|
|
|
assert!(desc.contains("videoconvert ! videoscale ! videorate !"));
|
|
|
|
|
assert!(!desc.contains("v4l2src device=\"/dev/video0\" do-timestamp=true ! video/x-raw,"));
|
|
|
|
|
}
|
2026-04-16 12:58:05 -03:00
|
|
|
|
2026-04-22 22:10:39 -03:00
|
|
|
#[test]
|
|
|
|
|
fn camera_preview_pipeline_requests_selected_webcam_quality_before_scaling() {
|
|
|
|
|
let desc =
|
|
|
|
|
camera_preview_pipeline_desc("/dev/video0", Some(CameraMode::new(1920, 1080, 30)));
|
|
|
|
|
assert!(
|
|
|
|
|
desc.contains("video/x-raw,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
desc.contains("image/jpeg,width=(int)1920,height=(int)1080,framerate=(fraction)30/1")
|
|
|
|
|
);
|
|
|
|
|
assert!(desc.contains("decodebin ! videoconvert ! videoscale"));
|
2026-04-23 04:46:21 -03:00
|
|
|
assert!(desc.contains("video/x-raw,format=RGBA,width=1920,height=1080,framerate=30/1"));
|
|
|
|
|
assert!(!desc.contains("width=128,height=72"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn camera_preview_mode_defaults_to_hd_and_tracks_selected_quality() {
|
|
|
|
|
assert_eq!(camera_preview_mode(None), (1280, 720, 30));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
camera_preview_mode(Some(CameraMode::new(1920, 1080, 30))),
|
|
|
|
|
(1920, 1080, 30)
|
|
|
|
|
);
|
2026-04-22 22:10:39 -03:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 08:07:09 -03:00
|
|
|
#[test]
|
|
|
|
|
fn microphone_monitor_uses_pulse_for_launcher_catalog_source_names() {
|
|
|
|
|
let desc = microphone_monitor_pipeline_desc(
|
|
|
|
|
"alsa_input.usb-Neat_Microphones_Bumblebee_II_USB_Microphone-00.mono-fallback",
|
|
|
|
|
None,
|
|
|
|
|
);
|
|
|
|
|
assert!(desc.contains("pulsesrc device=\"alsa_input.usb-Neat_Microphones"));
|
|
|
|
|
assert!(!desc.contains("pipewiresrc target-object"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 12:58:05 -03:00
|
|
|
#[test]
|
|
|
|
|
fn push_recent_audio_keeps_only_last_three_seconds() {
|
|
|
|
|
let buffer = Arc::new(Mutex::new(Vec::new()));
|
|
|
|
|
push_recent_audio(&buffer, &vec![1u8; MIC_REPLAY_MAX_BYTES / 2]);
|
|
|
|
|
push_recent_audio(&buffer, &vec![2u8; MIC_REPLAY_MAX_BYTES]);
|
|
|
|
|
let stored = buffer.lock().expect("buffer").clone();
|
|
|
|
|
assert_eq!(stored.len(), MIC_REPLAY_MAX_BYTES);
|
|
|
|
|
assert!(stored.iter().any(|byte| *byte == 2));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_wav_bytes_writes_a_valid_riff_header() {
|
|
|
|
|
let audio = vec![0u8; 32];
|
|
|
|
|
let wav = build_wav_bytes(&audio, 16_000, 1, 16);
|
|
|
|
|
assert!(wav.starts_with(b"RIFF"));
|
|
|
|
|
assert_eq!(&wav[8..12], b"WAVE");
|
|
|
|
|
assert_eq!(&wav[36..40], b"data");
|
|
|
|
|
assert_eq!(wav.len(), 44 + audio.len());
|
|
|
|
|
}
|
2026-04-22 05:46:31 -03:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_camera_preview_tap_round_trips_rgba_frame() {
|
|
|
|
|
let path =
|
|
|
|
|
std::env::temp_dir().join(format!("lesavka-camera-preview-tap-{}", std::process::id()));
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&path,
|
|
|
|
|
[b"LESAVKA_RGBA 2 2 8\n".as_slice(), &[1_u8; 16]].concat(),
|
|
|
|
|
)
|
|
|
|
|
.expect("write tap");
|
|
|
|
|
|
|
|
|
|
let frame = read_camera_preview_tap(&path).expect("read tap");
|
|
|
|
|
assert_eq!(frame.width, 2);
|
|
|
|
|
assert_eq!(frame.height, 2);
|
|
|
|
|
assert_eq!(frame.stride, 8);
|
|
|
|
|
assert_eq!(frame.rgba.len(), 16);
|
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn relay_microphone_level_tap_clamps_values() {
|
|
|
|
|
let path =
|
|
|
|
|
std::env::temp_dir().join(format!("lesavka-mic-level-tap-{}", std::process::id()));
|
|
|
|
|
std::fs::write(&path, "1.25\n").expect("write high");
|
|
|
|
|
assert_eq!(read_microphone_level_tap(&path), Some(1.0));
|
|
|
|
|
|
|
|
|
|
std::fs::write(&path, "-0.5\n").expect("write low");
|
|
|
|
|
assert_eq!(read_microphone_level_tap(&path), Some(0.0));
|
|
|
|
|
|
|
|
|
|
std::fs::write(&path, "not-a-number\n").expect("write invalid");
|
|
|
|
|
assert_eq!(read_microphone_level_tap(&path), None);
|
|
|
|
|
let _ = std::fs::remove_file(path);
|
|
|
|
|
}
|
2026-04-15 01:20:51 -03:00
|
|
|
}
|