diff --git a/scripts/daemon/lesavka-core.sh b/scripts/daemon/lesavka-core.sh index 761bf70..4f9918a 100644 --- a/scripts/daemon/lesavka-core.sh +++ b/scripts/daemon/lesavka-core.sh @@ -93,11 +93,15 @@ printf '\x05\x01\x09\x02\xa1\x01\x09\x01\xa1\x00'\ '\x05\x01\x09\x30\x09\x31\x09\x38\x15\x81\x25\x7f\x75\x08\x95\x03\x81\x06'\ '\xc0\xc0' >"$G/functions/hid.usb1/report_desc" -# # -- UAC2 Audio -# mkdir -p $G/functions/uac2.usb0 -# echo 48000 > $G/functions/uac2.usb0/c_srate -# echo 2 > $G/functions/uac2.usb0/c_ssize -# echo 2 > $G/functions/uac2.usb0/p_chmask +# ---------- UAC2 function – speaker + mic, 2Γ—48β€―kHz stereo --------- +mkdir -p "$G/functions/uac2.usb0" +cfg="$G/functions/uac2.usb0" +echo 48000 >"$cfg/s_spk/freq" +echo 2 >"$cfg/s_spk/chs" # L+R +echo 16 >"$cfg/s_spk/strm_maxpacket" +echo 48000 >"$cfg/c_mic/freq" +echo 2 >"$cfg/c_mic/chs" +echo 16 >"$cfg/c_mic/strm_maxpacket" # ----------------------- configuration ----------------------------- mkdir -p "$G/configs/c.1/strings/0x409" @@ -107,8 +111,12 @@ echo "Config 1" > "$G/configs/c.1/strings/0x409/configuration" # 6) Finally bind to first available UDC ln -s $G/functions/hid.usb0 $G/configs/c.1/ ln -s $G/functions/hid.usb1 $G/configs/c.1/ -# ln -s $G/functions/uac2.usb0 $G/configs/c.1/ +ln -sf "$cfg" "$G/configs/c.1/" # 7) Finally bind to first available UDC echo "$UDC" > "$G/UDC" -echo "[lesavka-core] πŸŽ‰ gadget ready on $UDC (keyboard: hidg0, mouse: hidg1)" +echo "[lesavka-core] πŸŽ‰ gadget ready" +echo "⌨️ hidg0 gadget ready on controller $UDC" +echo "πŸ–±οΈ hidg1 gadget ready on controller $UDC" +echo "🎧 UAC2 gadget ready on controller $UDC" +echo "🎀 UAC2 gadget ready on controller $UDC" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index f9d6058..c3ac4ac 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -4,7 +4,20 @@ set -euo pipefail ORIG_USER=${SUDO_USER:-$(id -un)} echo "==> 1a. Base packages" -sudo pacman -Syq --needed --noconfirm git rustup protobuf gcc pipewire pipewire-pulse tailscale base-devel gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav +sudo pacman -Syq --needed --noconfirm git \ + rustup \ + protobuf \ + gcc \ + pipewire \ + pipewire-pulse \ + tailscale \ + base-devel \ + gstreamer \ + gst-plugins-base \ + gst-plugins-good \ + gst-plugins-bad \ + gst-plugins-ugly \ + gst-libav if ! command -v yay >/dev/null 2>&1; then echo "==> 1b. installing yay from AUR ..." sudo -u "$ORIG_USER" bash -c ' diff --git a/server/src/audio.rs b/server/src/audio.rs index 8aadad7..599126e 100644 --- a/server/src/audio.rs +++ b/server/src/audio.rs @@ -1,19 +1,21 @@ +// server/src/audio.rs +#![forbid(unsafe_code)] + use anyhow::Context; use futures_util::Stream; use gstreamer as gst; use gstreamer_app as gst_app; +use gst::prelude::*; use lesavka_common::lesavka::AudioPacket; use tokio_stream::wrappers::ReceiverStream; use tonic::Status; -use tracing::{debug, trace}; -use gst::prelude::*; - -const EAR_ID: [&str; 2] = ["l", "r"]; -const PIPE: &str = "appsrc name=audsrc is-live=true do-timestamp=true ! aacparse ! queue ! appsink name=asink emit-signals=true"; +use tracing::{debug, error, warn}; +/// β€œSpeaker” stream coming **from** the remote host (UAC2‑gadget playback +/// endpoint) **towards** the client. pub struct AudioStream { - _pipe: gst::Pipeline, - inner: ReceiverStream>, + _pipeline: gst::Pipeline, + inner: ReceiverStream>, } impl Stream for AudioStream { @@ -26,51 +28,90 @@ impl Stream for AudioStream { } } -// pub async fn eye_ear( -// dev: &str, -// id: u32, -// ) -> anyhow::Result { -// let ear = EAR_ID[id as usize]; -// gst::init().context("gst init")?; -// let desc = format!( -// "v4l2src device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ -// video/mpegts,systemstream=true,packetsize=188 ! \ -// tsdemux name=d d.audio_0 ! queue ! \ -// aacparse ! queue ! \ -// appsink name=asink emit-signals=true max-buffers=64 drop=true" -// ); -// let pipe: gst::Pipeline = gst::parse::launch(&desc)? -// .downcast() -// .expect("pipeline"); +impl Drop for AudioStream { + fn drop(&mut self) { + let _ = self._pipeline.set_state(gst::State::Null); + } +} -// let sink = pipe.by_name("asink").expect("asink").downcast::().unwrap(); -// let (tx, rx) = tokio::sync::mpsc::channel(8192); +/*───────────────────────────────────────────────────────────────────────────*/ +/* eye_ear() – capture from ALSA (β€œspeaker”) and push AAC AUs via gRPC */ +/*───────────────────────────────────────────────────────────────────────────*/ -// sink.set_callbacks( -// gst_app::AppSinkCallbacks::builder() -// .new_sample(move |s| { -// let samp = s.pull_sample().map_err(|_| gst::FlowError::Eos)?; -// let buf = samp.buffer().ok_or(gst::FlowError::Error)?; -// let map = buf.map_readable().map_err(|_| gst::FlowError::Error)?; -// let pts = buf.pts().unwrap_or(gst::ClockTime::ZERO).nseconds()/1_000; -// let pkt = AudioPacket { id, pts, data: map.as_slice().to_vec() }; -// debug!(target:"lesavka_server::audio", -// eye = id, bytes = pkt.data.len(), pts = pkt.pts, -// "⬆️ pushed audio sample ear-{ear}"); -// let _ = tx.try_send(Ok(pkt)); -// Ok(gst::FlowSuccess::Ok) -// }).build() -// ); +pub async fn eye_ear(alsa_dev: &str, id: u32) -> anyhow::Result { + // NB: one *logical* speaker β†’ id==0. A 2nd logical stream could be + // added later (for multi‑channel) without changing the client. + gst::init().context("gst init")?; -// pipe.set_state(gst::State::Playing)?; -// Ok(AudioStream{ _pipe: pipe, inner: ReceiverStream::new(rx) }) -// } + /*──────────── pipeline description ──────────── + * + * ALSA (UAC2 gadget) AAC+ADTS AppSink + * β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” raw 48β€―kHz β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” AU/ADTS β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + * β”‚ alsasrc │────────────► voaacenc │────────► appsink β”‚ + * β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + */ + let desc = format!( + "alsasrc name=audsrc device=\"{alsa_dev}\" do-timestamp=true ! \ + audio/x-raw,channels=2,rate=48000 ! \ + voaacenc bitrate=192000 ! aacparse ! queue ! \ + appsink name=asink emit-signals=true max-buffers=64 drop=true" + ); + + let pipeline: gst::Pipeline = gst::parse::launch(&desc)? + .downcast() + .expect("pipeline"); + + let sink: gst_app::AppSink = pipeline + .by_name("asink") + .expect("asink") + .downcast() + .expect("appsink"); + + let (tx, rx) = tokio::sync::mpsc::channel(8192); + + /*──────────── callbacks ────────────*/ + sink.set_callbacks( + gst_app::AppSinkCallbacks::builder() + .new_sample(move |s| { + 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)?; + + static CNT: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let n = CNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if n < 10 || n % 300 == 0 { + debug!("πŸ”Š eye‑ear #{n}: {}β€―bytes", map.len()); + } + + let pts_us = buffer + .pts() + .unwrap_or(gst::ClockTime::ZERO) + .nseconds() / 1_000; + + // push non‑blocking; drop oldest on overflow + if tx.try_send(Ok(AudioPacket { + id, + pts: pts_us, + data: map.as_slice().to_vec(), + })).is_err() { + static DROPS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + let d = DROPS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if d % 300 == 0 { + warn!("πŸ”ŠπŸ’” dropped {d} audio AUs (client too slow)"); + } + } + Ok(gst::FlowSuccess::Ok) + }) + .build(), + ); + + pipeline.set_state(gst::State::Playing) + .context("starting audio pipeline")?; -pub async fn eye_ear(_dev: &str, _id: u32) -> anyhow::Result { - use tokio_stream::StreamExt; - let (_tx, rx) = tokio::sync::mpsc::channel(1); Ok(AudioStream { - _pipe: gst::Pipeline::new(), - inner: ReceiverStream::new(rx), // always empty + _pipeline: pipeline, + inner: ReceiverStream::new(rx), }) } diff --git a/server/src/main.rs b/server/src/main.rs index eebc1f1..bfcc470 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -186,15 +186,17 @@ impl Relay for Handler { &self, req: Request, ) -> Result, Status> { - // let id = req.into_inner().id; - // let dev = match id { 0 => "/dev/lesavka_l_eye", - // 1 => "/dev/lesavka_r_eye", - // _ => return Err(Status::invalid_argument("monitor id must be 0 or 1")) }; - // let s = audio::eye_ear(dev, id) - // .await - // .map_err(|e| Status::internal(format!("{e:#}")))?; - // Ok(Response::new(Box::pin(s))) - Err(Status::unimplemented("GC311 has no embedded audio")) + // Only one speaker stream for now; both 0/1 β†’ same ALSA dev. + let _id = req.into_inner().id; + // Allow override (`LESAVKA_ALSA_DEV=hw:2,0` for debugging). + let dev = std::env::var("LESAVKA_ALSA_DEV") + .unwrap_or_else(|_| "hw:UAC2Gadget,0".into()); + + let s = audio::eye_ear(&dev, 0) + .await + .map_err(|e| Status::internal(format!("{e:#}")))?; + + Ok(Response::new(Box::pin(s))) } /*────────────── USB-reset RPC ───────────*/