Mic Setup

This commit is contained in:
Brad Stein 2025-06-30 19:35:38 -05:00
parent f60736990b
commit 02bdc5d76b
12 changed files with 224 additions and 41 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.4.0" version = "0.5.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
@ -27,6 +27,7 @@ winit = "0.30"
raw-window-handle = "0.6" raw-window-handle = "0.6"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
async-stream = "0.3"
[build-dependencies] [build-dependencies]
prost-build = "0.13" prost-build = "0.13"

View File

@ -17,6 +17,7 @@ use lesavka_common::lesavka::{
}; };
use crate::{input::inputs::InputAggregator, use crate::{input::inputs::InputAggregator,
input::microphone::MicrophoneCapture,
output::video::MonitorWindow, output::video::MonitorWindow,
output::audio::AudioOut}; output::audio::AudioOut};
@ -98,7 +99,7 @@ impl LesavkaClientApp {
while let Ok(pkt) = video_rx.try_recv() { while let Ok(pkt) = video_rx.try_recv() {
CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 { if CNT.load(std::sync::atomic::Ordering::Relaxed) % 300 == 0 {
tracing::debug!("🎥 received {} video packets", CNT.load(std::sync::atomic::Ordering::Relaxed)); debug!("🎥 received {} video packets", CNT.load(std::sync::atomic::Ordering::Relaxed));
} }
let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let n = DUMP_CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if n % 120 == 0 { if n % 120 == 0 {
@ -125,6 +126,11 @@ impl LesavkaClientApp {
tokio::spawn(Self::audio_loop(ep_audio, audio_out)); tokio::spawn(Self::audio_loop(ep_audio, audio_out));
/*────────── microphone gRPC pusher ───────────*/
let mic = Arc::new(MicrophoneCapture::new()?);
let ep_mic = vid_ep.clone();
tokio::spawn(Self::mic_loop(ep_mic, mic));
/*────────── central reactor ───────────────────*/ /*────────── central reactor ───────────────────*/
tokio::select! { tokio::select! {
_ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); }, _ = kbd_loop => { warn!("⚠️⌨️ keyboard stream finished"); },
@ -149,20 +155,19 @@ impl LesavkaClientApp {
/*──────────────── keyboard stream ───────────────*/ /*──────────────── keyboard stream ───────────────*/
async fn stream_loop_keyboard(&self, ep: Channel) { async fn stream_loop_keyboard(&self, ep: Channel) {
loop { loop {
info!("⌨️🤙 dial {}", self.server_addr); // LESAVKA-client info!("⌨️🤙 Keyboard dial {}", self.server_addr);
let mut cli = RelayClient::new(ep.clone()); let mut cli = RelayClient::new(ep.clone());
// ✅ use kbd_tx here - fixes E0271
let outbound = BroadcastStream::new(self.kbd_tx.subscribe()) let outbound = BroadcastStream::new(self.kbd_tx.subscribe())
.filter_map(|r| r.ok()); .filter_map(|r| r.ok());
match cli.stream_keyboard(Request::new(outbound)).await { match cli.stream_keyboard(Request::new(outbound)).await {
Ok(mut resp) => { Ok(mut resp) => {
while let Some(msg) = resp.get_mut().message().await.transpose() { while let Some(msg) = resp.get_mut().message().await.transpose() {
if let Err(e) = msg { warn!("⌨️ server err: {e}"); break; } if let Err(e) = msg { warn!("⌨️ server err: {e}"); break; }
} }
} }
Err(e) => warn!("⌨️ connect failed: {e}"), Err(e) => warn!("⌨️ connect failed: {e}"),
} }
tokio::time::sleep(Duration::from_secs(1)).await; // retry tokio::time::sleep(Duration::from_secs(1)).await; // retry
} }
@ -171,7 +176,7 @@ impl LesavkaClientApp {
/*──────────────── mouse stream ──────────────────*/ /*──────────────── mouse stream ──────────────────*/
async fn stream_loop_mouse(&self, ep: Channel) { async fn stream_loop_mouse(&self, ep: Channel) {
loop { loop {
info!("🖱️🤙 dial {}", self.server_addr); info!("🖱️🤙 Mouse dial {}", self.server_addr);
let mut cli = RelayClient::new(ep.clone()); let mut cli = RelayClient::new(ep.clone());
let outbound = BroadcastStream::new(self.mou_tx.subscribe()) let outbound = BroadcastStream::new(self.mou_tx.subscribe())
@ -180,10 +185,10 @@ impl LesavkaClientApp {
match cli.stream_mouse(Request::new(outbound)).await { match cli.stream_mouse(Request::new(outbound)).await {
Ok(mut resp) => { Ok(mut resp) => {
while let Some(msg) = resp.get_mut().message().await.transpose() { while let Some(msg) = resp.get_mut().message().await.transpose() {
if let Err(e) = msg { warn!("🖱️ server err: {e}"); break; } if let Err(e) = msg { warn!("🖱️ server err: {e}"); break; }
} }
} }
Err(e) => warn!("🖱️ connect failed: {e}"), Err(e) => warn!("🖱️ connect failed: {e}"),
} }
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
@ -203,7 +208,7 @@ impl LesavkaClientApp {
let req = MonitorRequest { id: monitor_id, max_bitrate: 6_000 }; let req = MonitorRequest { id: monitor_id, max_bitrate: 6_000 };
match cli.capture_video(Request::new(req)).await { match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {
info!("🎥 cli video{monitor_id}: stream opened"); debug!("🎥 cli video{monitor_id}: stream opened");
while let Some(res) = stream.get_mut().message().await.transpose() { while let Some(res) = stream.get_mut().message().await.transpose() {
match res { match res {
Ok(pkt) => { Ok(pkt) => {
@ -245,4 +250,30 @@ impl LesavkaClientApp {
tokio::time::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
} }
/*──────────────── mic stream ─────────────────*/
async fn mic_loop(ep: Channel, mic: Arc<MicrophoneCapture>) {
loop {
let mut cli = RelayClient::new(ep.clone());
let mic_clone = mic.clone();
let stream = async_stream::stream! {
loop {
if let Some(pkt) = mic_clone.pull() {
yield pkt;
} else {
break; // EOS should not happen
}
}
};
match cli.stream_microphone(Request::new(stream)).await {
Ok(mut resp) => {
while resp.get_mut().message().await?.is_some() {}
}
Err(e) => warn!("🎤 connect failed: {e}"),
}
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
} }

View File

@ -20,7 +20,6 @@ pub struct InputAggregator {
keyboards: Vec<KeyboardAggregator>, keyboards: Vec<KeyboardAggregator>,
mice: Vec<MouseAggregator>, mice: Vec<MouseAggregator>,
camera: Option<CameraCapture>, camera: Option<CameraCapture>,
mic: Option<MicrophoneCapture>,
} }
impl InputAggregator { impl InputAggregator {
@ -29,7 +28,7 @@ impl InputAggregator {
mou_tx: Sender<MouseReport>) -> Self { mou_tx: Sender<MouseReport>) -> Self {
Self { kbd_tx, mou_tx, dev_mode, released: false, magic_active: false, Self { kbd_tx, mou_tx, dev_mode, released: false, magic_active: false,
keyboards: Vec::new(), mice: Vec::new(), keyboards: Vec::new(), mice: Vec::new(),
camera: None, mic: None } camera: None }
} }
/// Called once at startup: enumerates input devices, /// Called once at startup: enumerates input devices,
@ -96,7 +95,6 @@ impl InputAggregator {
// Stubs for camera / mic: // Stubs for camera / mic:
self.camera = Some(CameraCapture::new_stub()); self.camera = Some(CameraCapture::new_stub());
self.mic = Some(MicrophoneCapture::new_stub());
Ok(()) Ok(())
} }
@ -135,8 +133,6 @@ impl InputAggregator {
mouse.process_events(); mouse.process_events();
} }
// camera / mic stubs go here
self.magic_active = magic_now; self.magic_active = magic_now;
tick.tick().await; tick.tick().await;
} }

View File

@ -1,19 +1,77 @@
// client/src/input/microphone.rs // client/src/input/microphone.rs
use anyhow::Result; #![forbid(unsafe_code)]
use anyhow::{Context, Result};
use gstreamer as gst;
use gstreamer_app as gst_app;
use gst::prelude::*;
use lesavka_common::lesavka::AudioPacket;
use tracing::{debug, error, info, warn};
pub struct MicrophoneCapture { pub struct MicrophoneCapture {
// no real fields yet pipeline: gst::Pipeline,
sink: gst_app::AppSink,
} }
impl MicrophoneCapture { impl MicrophoneCapture {
pub fn new_stub() -> Self { pub fn new() -> Result<Self> {
// real code would open /dev/snd, or use cpal / rodio / etc gst::init().ok(); // idempotent
MicrophoneCapture {}
/* pulsesrc (default mic) → AAC/ADTS → appsink -------------------*/
let desc = concat!(
"pulsesrc do-timestamp=true ! ",
"audio/x-raw,format=S16LE,channels=2,rate=48000 ! ",
"audioconvert ! audioresample ! avenc_aac bitrate=128000 ! ",
"aacparse ! capsfilter caps=audio/mpeg,stream-format=adts,rate=48000,channels=2 ! ",
"appsink name=asink emit-signals=true max-buffers=50 drop=true"
);
let pipeline: gst::Pipeline = gst::parse::launch(desc)?
.downcast()
.expect("pipeline");
let sink: gst_app::AppSink = pipeline
.by_name("asink")
.unwrap()
.downcast()
.unwrap();
/* ─── bus for diagnostics ───────────────────────────────────────*/
{
let bus = pipeline.bus().unwrap();
std::thread::spawn(move || {
use gst::MessageView::*;
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
StateChanged(s) if s.current() == gst::State::Playing
&& msg.src().map(|s| s.is::<gst::Pipeline>()).unwrap_or(false) =>
info!("🎤 mic pipeline ▶️ (source=pulsesrc)"),
Error(e) =>
error!("🎤💥 mic: {} ({})", e.error(), e.debug().unwrap_or_default()),
Warning(w) =>
warn!("🎤⚠️ mic: {} ({})", w.error(), w.debug().unwrap_or_default()),
_ => {}
}
}
});
}
pipeline.set_state(gst::State::Playing)
.context("start mic pipeline")?;
Ok(Self { pipeline, sink })
} }
pub fn capture_audio(&mut self) -> Result<()> { /// Blocking pull; call from an async wrapper
// no-op pub fn pull(&self) -> Option<AudioPacket> {
Ok(()) match self.sink.pull_sample() {
Ok(sample) => {
let buf = sample.buffer().unwrap();
let map = buf.map_readable().unwrap();
let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds() / 1_000;
Some(AudioPacket { id: 0, pts, data: map.as_slice().to_vec() })
}
Err(_) => None,
}
} }
} }

View File

@ -66,7 +66,7 @@ impl AudioOut {
.build() .build()
)); ));
src.set_format(gst::Format::Time); src.set_format(gst::Format::Time);
// ── 4. Log *all* warnings/errors from the bus ────────────────────── // ── 4. Log *all* warnings/errors from the bus ──────────────────────
let bus = pipeline.bus().unwrap(); let bus = pipeline.bus().unwrap();
std::thread::spawn(move || { std::thread::spawn(move || {
@ -88,9 +88,9 @@ impl AudioOut {
.map(|s| s.is::<gst::Pipeline>()) .map(|s| s.is::<gst::Pipeline>())
.unwrap_or(false) .unwrap_or(false)
{ {
info!("🔊 audio pipeline PLAYING (sink='{}')", sink); info!("🔊 audio pipeline ▶️ (sink='{}')", sink);
} else { } else {
debug!("🔊 element {} now PLAYING", debug!("🔊 element {} now ▶️",
msg.src().map(|s| s.name()).unwrap_or_default()); msg.src().map(|s| s.name()).unwrap_or_default());
} }
}, },

View File

@ -149,6 +149,39 @@ impl MonitorWindow {
} }
} }
{
let id = id; // move into thread
let bus = pipeline.bus().expect("no bus");
std::thread::spawn(move || {
use gst::MessageView::*;
for msg in bus.iter_timed(gst::ClockTime::NONE) {
match msg.view() {
StateChanged(s) if s.current() == gst::State::Playing => {
if msg
.src()
.map(|s| s.is::<gst::Pipeline>())
.unwrap_or(false)
{
info!(
"🎞️ video{id} pipeline ▶️ (sink='glimagesink')"
);
}
}
Error(e) => error!(
"💥 gst video{id}: {} ({})",
e.error(),
e.debug().unwrap_or_default()
),
Warning(w) => warn!(
"⚠️ gst video{id}: {} ({})",
w.error(),
w.debug().unwrap_or_default()
),
_ => {}
}
}
});
}
pipeline.set_state(gst::State::Playing)?; pipeline.set_state(gst::State::Playing)?;

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.4.0" version = "0.5.0"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -8,13 +8,15 @@ message MonitorRequest { uint32 id = 1; uint32 max_bitrate = 2; }
message VideoPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; } message VideoPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; } message AudioPacket { uint32 id = 1; uint64 pts = 2; bytes data = 3; }
message ResetUsbRequest {} // empty body message ResetUsbReply { bool ok = 1; } // true = success
message ResetUsbReply { bool ok = 1; } // true = success
message Empty {}
service Relay { service Relay {
rpc StreamKeyboard (stream KeyboardReport) returns (stream KeyboardReport); rpc StreamKeyboard (stream KeyboardReport) returns (stream KeyboardReport);
rpc StreamMouse (stream MouseReport) returns (stream MouseReport); rpc StreamMouse (stream MouseReport) returns (stream MouseReport);
rpc CaptureVideo (MonitorRequest) returns (stream VideoPacket); rpc CaptureVideo (MonitorRequest) returns (stream VideoPacket);
rpc CaptureAudio (MonitorRequest) returns (stream AudioPacket); rpc CaptureAudio (MonitorRequest) returns (stream AudioPacket);
rpc ResetUsb (ResetUsbRequest) returns (ResetUsbReply); rpc StreamMicrophone (stream AudioPacket) returns (stream Empty) {}
rpc ResetUsb (Empty) returns (ResetUsbReply);
} }

View File

@ -122,6 +122,10 @@ ln -s $G/functions/hid.usb0 $G/configs/c.1/
ln -s $G/functions/hid.usb1 $G/configs/c.1/ ln -s $G/functions/hid.usb1 $G/configs/c.1/
ln -s "$U" "$G/configs/c.1/" ln -s "$U" "$G/configs/c.1/"
echo "Lesavka Keyboard" >$G/functions/hid.usb0/os_desc/interface
echo "Lesavka Mouse" >$G/functions/hid.usb1/os_desc/interface
echo "Lesavka Mic+Spkr" >$U/os_desc/interface
#────────────────────────────────────────────────── #──────────────────────────────────────────────────
# 4. Bind gadget # 4. Bind gadget
#────────────────────────────────────────────────── #──────────────────────────────────────────────────

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.4.0" version = "0.5.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -136,6 +136,30 @@ pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
}) })
} }
pub async fn voice(
alsa_dev: &str,
) -> anyhow::Result<(gst::Pipeline, gst_app::AppSrc)> {
gst::init()?;
let desc = format!(
"appsrc name=src is-live=true format=time do-timestamp=true ! \
aacparse ! avdec_aac ! audioconvert ! audioresample ! \
alsasink device=\"{alsa_dev}\""
);
let pipeline: gst::Pipeline = gst::parse::launch(&desc)?
.downcast()
.unwrap();
let src: gst_app::AppSrc = pipeline
.by_name("src")
.unwrap()
.downcast()
.unwrap();
pipeline.set_state(gst::State::Playing)?;
Ok((pipeline, src))
}
/*────────────────────────── build_pipeline_desc ───────────────────────────*/ /*────────────────────────── build_pipeline_desc ───────────────────────────*/
fn build_pipeline_desc(dev: &str) -> anyhow::Result<String> { fn build_pipeline_desc(dev: &str) -> anyhow::Result<String> {
let reg = gst::Registry::get(); let reg = gst::Registry::get();

View File

@ -11,6 +11,7 @@ use tokio::{
io::AsyncWriteExt, io::AsyncWriteExt,
sync::Mutex, sync::Mutex,
}; };
use gstreamer as gst;
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status}; use tonic::{Request, Response, Status};
use tonic::transport::Server; use tonic::transport::Server;
@ -20,7 +21,7 @@ use tracing_subscriber::{filter::EnvFilter, fmt, prelude::*};
use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::non_blocking::WorkerGuard;
use lesavka_common::lesavka::{ use lesavka_common::lesavka::{
ResetUsbRequest, ResetUsbReply, Empty, ResetUsbReply,
relay_server::{Relay, RelayServer}, relay_server::{Relay, RelayServer},
KeyboardReport, MouseReport, KeyboardReport, MouseReport,
MonitorRequest, VideoPacket, AudioPacket MonitorRequest, VideoPacket, AudioPacket
@ -118,10 +119,11 @@ impl Handler {
#[tonic::async_trait] #[tonic::async_trait]
impl Relay for Handler { impl Relay for Handler {
/* existing streams ─ unchanged, except: no more auto-reset */ /* existing streams ─ unchanged, except: no more auto-reset */
type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>; type StreamKeyboardStream = ReceiverStream<Result<KeyboardReport, Status>>;
type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>; type StreamMouseStream = ReceiverStream<Result<MouseReport, Status>>;
type CaptureVideoStream = Pin<Box<dyn Stream<Item=Result<VideoPacket,Status>> + Send>>; type CaptureVideoStream = Pin<Box<dyn Stream<Item=Result<VideoPacket,Status>> + Send>>;
type CaptureAudioStream = Pin<Box<dyn Stream<Item=Result<AudioPacket,Status>> + Send>>; type CaptureAudioStream = Pin<Box<dyn Stream<Item=Result<AudioPacket,Status>> + Send>>;
type StreamMicrophoneStream = ReceiverStream<Result<Empty, Status>>;
async fn stream_keyboard( async fn stream_keyboard(
&self, &self,
@ -165,6 +167,38 @@ impl Relay for Handler {
Ok(Response::new(ReceiverStream::new(rx))) Ok(Response::new(ReceiverStream::new(rx)))
} }
async fn stream_microphone(
&self,
req: Request<tonic::Streaming<AudioPacket>>,
) -> Result<Response<Self::StreamMicrophoneStream>, Status> {
// Build playback pipeline (AppSrc → alsasink)
let (_pipeline, src) = audio::voice("hw:UAC2Gadget,0")
.await
.map_err(|e| Status::internal(format!("{e:#}")))?;
// channel just to satisfy the “stream Empty” return type
let (tx, rx) = tokio::sync::mpsc::channel(1);
// forward packets from gRPC to AppSrc
tokio::spawn(async move {
let mut inbound = req.into_inner();
while let Some(pkt) = inbound.next().await.transpose()? {
let mut buf = gst::Buffer::from_slice(pkt.data);
buf.get_mut().unwrap()
.set_pts(Some(gst::ClockTime::from_useconds(pkt.pts)));
if let Err(e) = src.push_buffer(buf) {
warn!("🎤 AppSrc push failed: {e:?}");
}
}
// optional: send a single Empty to show EOS
let _ = tx.send(Ok(Empty {})).await;
Result::<(), Status>::Ok(())
});
Ok(Response::new(ReceiverStream::new(rx)))
}
async fn capture_video( async fn capture_video(
&self, &self,
req: Request<MonitorRequest>, req: Request<MonitorRequest>,
@ -202,7 +236,7 @@ impl Relay for Handler {
/*────────────── USB-reset RPC ───────────*/ /*────────────── USB-reset RPC ───────────*/
async fn reset_usb( async fn reset_usb(
&self, &self,
_req: Request<ResetUsbRequest>, _req: Request<Empty>,
) -> Result<Response<ResetUsbReply>, Status> { ) -> Result<Response<ResetUsbReply>, Status> {
info!("🔴 explicit ResetUsb() called"); info!("🔴 explicit ResetUsb() called");
match self.gadget.cycle() { match self.gadget.cycle() {