2025-06-29 22:57:54 -05:00
|
|
|
|
// server/src/audio.rs
|
|
|
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
|
|
2025-06-30 14:20:07 -05:00
|
|
|
|
use anyhow::{anyhow, Context};
|
|
|
|
|
|
use chrono::Local;
|
2025-06-29 03:46:34 -05:00
|
|
|
|
use futures_util::Stream;
|
|
|
|
|
|
use gstreamer as gst;
|
|
|
|
|
|
use gstreamer_app as gst_app;
|
2025-06-29 22:57:54 -05:00
|
|
|
|
use gst::prelude::*;
|
2025-06-30 14:20:07 -05:00
|
|
|
|
use gst::{ElementFactory, MessageView};
|
2025-06-30 02:42:20 -05:00
|
|
|
|
use gst::MessageView::*;
|
2025-06-29 03:46:34 -05:00
|
|
|
|
use lesavka_common::lesavka::AudioPacket;
|
|
|
|
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
|
|
use tonic::Status;
|
2025-06-29 22:57:54 -05:00
|
|
|
|
use tracing::{debug, error, warn};
|
2025-06-30 14:20:07 -05:00
|
|
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
2025-06-29 03:46:34 -05:00
|
|
|
|
|
2025-06-29 22:57:54 -05:00
|
|
|
|
/// “Speaker” stream coming **from** the remote host (UAC2‑gadget playback
|
|
|
|
|
|
/// endpoint) **towards** the client.
|
2025-06-29 03:46:34 -05:00
|
|
|
|
pub struct AudioStream {
|
2025-06-29 22:57:54 -05:00
|
|
|
|
_pipeline: gst::Pipeline,
|
|
|
|
|
|
inner: ReceiverStream<Result<AudioPacket, Status>>,
|
2025-06-29 03:46:34 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl Stream for AudioStream {
|
|
|
|
|
|
type Item = Result<AudioPacket, Status>;
|
|
|
|
|
|
fn poll_next(
|
|
|
|
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
|
|
|
|
cx: &mut std::task::Context<'_>,
|
|
|
|
|
|
) -> std::task::Poll<Option<Self::Item>> {
|
|
|
|
|
|
Stream::poll_next(std::pin::Pin::new(&mut self.inner), cx)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-06-29 22:57:54 -05:00
|
|
|
|
impl Drop for AudioStream {
|
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
|
let _ = self._pipeline.set_state(gst::State::Null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*───────────────────────────────────────────────────────────────────────────*/
|
2025-06-30 02:42:20 -05:00
|
|
|
|
/* ear() – capture from ALSA (“speaker”) and push AAC AUs via gRPC */
|
2025-06-29 22:57:54 -05:00
|
|
|
|
/*───────────────────────────────────────────────────────────────────────────*/
|
|
|
|
|
|
|
2025-06-30 02:42:20 -05:00
|
|
|
|
pub async fn ear(alsa_dev: &str, id: u32) -> anyhow::Result<AudioStream> {
|
2025-06-29 22:57:54 -05:00
|
|
|
|
// 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")?;
|
|
|
|
|
|
|
|
|
|
|
|
/*──────────── pipeline description ────────────
|
|
|
|
|
|
*
|
|
|
|
|
|
* ALSA (UAC2 gadget) AAC+ADTS AppSink
|
|
|
|
|
|
* ┌───────────┐ raw 48 kHz ┌─────────┐ AU/ADTS ┌──────────┐
|
|
|
|
|
|
* │ alsasrc │────────────► voaacenc │────────► appsink │
|
|
|
|
|
|
* └───────────┘ └─────────┘ └──────────┘
|
|
|
|
|
|
*/
|
2025-06-30 01:19:28 -05:00
|
|
|
|
let desc = build_pipeline_desc(alsa_dev)?;
|
2025-06-29 22:57:54 -05:00
|
|
|
|
|
|
|
|
|
|
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");
|
2025-06-30 14:20:07 -05:00
|
|
|
|
if let Some(tap) = pipeline
|
|
|
|
|
|
.by_name("debugtap")
|
|
|
|
|
|
.and_then(|e| e.downcast::<gst_app::AppSink>().ok())
|
|
|
|
|
|
{
|
|
|
|
|
|
clip_tap(tap);
|
|
|
|
|
|
}
|
2025-06-29 22:57:54 -05:00
|
|
|
|
|
|
|
|
|
|
let (tx, rx) = tokio::sync::mpsc::channel(8192);
|
|
|
|
|
|
|
2025-06-30 02:42:20 -05:00
|
|
|
|
let bus = pipeline.bus().expect("bus");
|
2025-06-30 02:03:01 -05:00
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
for msg in bus.iter_timed(gst::ClockTime::NONE) {
|
|
|
|
|
|
match msg.view() {
|
|
|
|
|
|
Error(e) => error!("💥 audio pipeline: {} ({})",
|
2025-06-30 02:42:20 -05:00
|
|
|
|
e.error(), e.debug().unwrap_or_default()),
|
2025-06-30 02:03:01 -05:00
|
|
|
|
Warning(w) => warn!("⚠️ audio pipeline: {} ({})",
|
2025-06-30 02:42:20 -05:00
|
|
|
|
w.error(), w.debug().unwrap_or_default()),
|
2025-06-30 02:03:01 -05:00
|
|
|
|
StateChanged(s) if s.current() == gst::State::Playing =>
|
|
|
|
|
|
debug!("🎶 audio pipeline PLAYING"),
|
|
|
|
|
|
_ => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-06-29 22:57:54 -05:00
|
|
|
|
/*──────────── 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 {
|
2025-06-30 02:42:20 -05:00
|
|
|
|
debug!("🎧 ear #{n}: {} bytes", map.len());
|
2025-06-29 22:57:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 {
|
2025-06-30 02:42:20 -05:00
|
|
|
|
warn!("🎧💔 dropped {d} audio AUs (client too slow)");
|
2025-06-29 22:57:54 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(gst::FlowSuccess::Ok)
|
|
|
|
|
|
})
|
|
|
|
|
|
.build(),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
pipeline.set_state(gst::State::Playing)
|
|
|
|
|
|
.context("starting audio pipeline")?;
|
|
|
|
|
|
|
2025-06-29 17:24:19 -05:00
|
|
|
|
Ok(AudioStream {
|
2025-06-29 22:57:54 -05:00
|
|
|
|
_pipeline: pipeline,
|
|
|
|
|
|
inner: ReceiverStream::new(rx),
|
2025-06-29 17:24:19 -05:00
|
|
|
|
})
|
2025-06-29 03:46:34 -05:00
|
|
|
|
}
|
2025-06-30 01:19:28 -05:00
|
|
|
|
|
2025-06-30 14:20:07 -05:00
|
|
|
|
/*────────────────────────── build_pipeline_desc ───────────────────────────*/
|
2025-06-30 01:19:28 -05:00
|
|
|
|
fn build_pipeline_desc(dev: &str) -> anyhow::Result<String> {
|
2025-06-30 11:38:57 -05:00
|
|
|
|
let reg = gst::Registry::get();
|
2025-06-30 12:34:27 -05:00
|
|
|
|
|
2025-06-30 14:20:07 -05:00
|
|
|
|
// first available encoder
|
|
|
|
|
|
let enc = ["fdkaacenc", "voaacenc", "avenc_aac"]
|
2025-06-30 01:19:28 -05:00
|
|
|
|
.into_iter()
|
2025-06-30 11:38:57 -05:00
|
|
|
|
.find(|&e| {
|
|
|
|
|
|
reg.find_plugin(e).is_some()
|
2025-06-30 14:20:07 -05:00
|
|
|
|
|| reg
|
|
|
|
|
|
.find_feature(e, ElementFactory::static_type())
|
|
|
|
|
|
.is_some()
|
2025-06-30 11:38:57 -05:00
|
|
|
|
})
|
|
|
|
|
|
.ok_or_else(|| anyhow!("no AAC encoder plugin available"))?;
|
2025-06-30 01:19:28 -05:00
|
|
|
|
|
2025-06-30 14:20:07 -05:00
|
|
|
|
// one long literal assembled with `concat!` so Rust sees *one* string
|
2025-06-30 01:19:28 -05:00
|
|
|
|
Ok(format!(
|
2025-06-30 14:20:07 -05:00
|
|
|
|
concat!(
|
|
|
|
|
|
"alsasrc device=\"{dev}\" do-timestamp=true ! ",
|
|
|
|
|
|
"audio/x-raw,format=S16LE,channels=2,rate=48000 ! ",
|
|
|
|
|
|
"audioconvert ! audioresample ! {enc} bitrate=192000 ! ",
|
|
|
|
|
|
"aacparse ! ",
|
|
|
|
|
|
"capsfilter caps=audio/mpeg,stream-format=adts,channels=2,rate=48000 ! ",
|
|
|
|
|
|
"tee name=t ",
|
|
|
|
|
|
"t. ! queue ! appsink name=asink emit-signals=true ",
|
|
|
|
|
|
"t. ! queue ! appsink name=debugtap emit-signals=true max-buffers=500 drop=true"
|
|
|
|
|
|
),
|
|
|
|
|
|
dev = dev,
|
|
|
|
|
|
enc = enc
|
2025-06-30 01:19:28 -05:00
|
|
|
|
))
|
2025-06-30 14:20:07 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*────────────────────────────── clip_tap() ────────────────────────────────*/
|
|
|
|
|
|
/// Called once per pipeline; spawns a thread that writes a 1 s AAC file
|
|
|
|
|
|
/// at the start of every wall‑clock minute **while log‑level == TRACE**.
|
|
|
|
|
|
fn clip_tap(tap: gst_app::AppSink) {
|
|
|
|
|
|
use gst::prelude::*;
|
|
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
|
|
|
|
|
use std::fs::File;
|
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
|
|
|
|
|
|
|
let mut collecting = Vec::with_capacity(200_000); // ~1 s
|
|
|
|
|
|
let mut next_min_boundary = next_minute();
|
|
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
|
match tap.pull_sample() {
|
|
|
|
|
|
Ok(s) => {
|
|
|
|
|
|
let buf = s.buffer().unwrap();
|
|
|
|
|
|
let map = buf.map_readable().unwrap();
|
|
|
|
|
|
collecting.extend_from_slice(map.as_slice());
|
|
|
|
|
|
|
|
|
|
|
|
// once per minute boundary & trace‑level
|
|
|
|
|
|
if tracing::enabled!(tracing::Level::TRACE) &&
|
|
|
|
|
|
SystemTime::now() >= next_min_boundary {
|
|
|
|
|
|
if !collecting.is_empty() {
|
|
|
|
|
|
let ts = chrono::Local::now()
|
|
|
|
|
|
.format("%Y%m%d-%H%M%S")
|
|
|
|
|
|
.to_string();
|
|
|
|
|
|
let path = format!("/tmp/ear-{ts}.aac");
|
|
|
|
|
|
if let Ok(mut f) = File::create(&path) {
|
|
|
|
|
|
let _ = f.write_all(&collecting);
|
|
|
|
|
|
tracing::debug!("📼 wrote 1 s clip → {}", path);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
collecting.clear();
|
|
|
|
|
|
next_min_boundary = next_minute();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if collecting.len() > 192_000 { // keep at most ~1 s
|
|
|
|
|
|
collecting.truncate(192_000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
Err(_) => break, // EOS
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
fn next_minute() -> SystemTime {
|
|
|
|
|
|
let now = SystemTime::now()
|
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
let secs = now.as_secs();
|
|
|
|
|
|
let next = (secs / 60 + 1) * 60;
|
|
|
|
|
|
UNIX_EPOCH + Duration::from_secs(next)
|
|
|
|
|
|
}
|
2025-06-30 11:38:57 -05:00
|
|
|
|
}
|