Skip to content
This repository was archived by the owner on Nov 11, 2025. It is now read-only.

Commit 95b8729

Browse files
authored
Audio capture (#29)
* sender: init audio capture * use default slint features * remove HLS backend * sender: x11 capture * sender: pipewire audio capture * remove testkit * fmt * chores * fmt * chore: linux guards * update readme
1 parent 445b14b commit 95b8729

55 files changed

Lines changed: 1131 additions & 7666 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 164 additions & 667 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[workspace]
22
resolver = "2"
3-
members = [ "fcast-lib", "common", "receiver", "sender", "scap", "scap-gstreamer", "http", "android-sender", "testkit"]
3+
members = [ "fcast-lib", "common", "receiver", "sender", "android-sender"]
44

55
[workspace.dependencies]
66
gst-video = { package = "gstreamer-video", version = "0.23.5" }
@@ -13,8 +13,8 @@ env_logger = "0.11.6"
1313
serde = { version = "1", features = ["derive"] }
1414
serde_json = "1"
1515
rand = "0.9.0"
16-
slint = "1.11.0"
17-
slint-build = "1.11.0"
16+
slint = "1.12.0"
17+
slint-build = "1.12.0"
1818
anyhow = "1.0.98"
1919
futures = "0.3"
2020
tokio-stream = "0.1.17"

README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ The current platform support matrix looks like this:
1414

1515
#### Desktop
1616

17-
| |Linux (Wayland) |Linux (X11) |Windows |MacOS |
18-
|------------|-----------------|-------------|---------|----------|
19-
|OMSender |Yes |Yes |Yes |No |
20-
|OMReceiver |Yes |Yes |Yes |Untested |
17+
| |Linux (Wayland) |Linux (X11) |Windows |MacOS |
18+
|------------|-----------------|-------------|--------------|----------|
19+
|OMSender |Yes |Yes |~~Yes~~ No^1 |No^1 |
20+
|OMReceiver |Yes |Yes |Yes |Untested |
2121

22-
OMSender can cast to other FCast receivers as well.
22+
1: Support is planned
23+
24+
~~OMSender can cast to other FCast receivers as well.~~
2325

2426
OMReceiver is also an FCast receiver.
2527

android-sender/build.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ fn main() {
7777
cargo_link!("gstaudio-1.0");
7878
cargo_link!("gstapp-1.0");
7979
cargo_link!("gstrtp-1.0");
80-
cargo_link!("gstwebrtc-1.0");
8180

8281
const DEFAULT_CLANG_VERSION: &str = "20";
8382
let clang_version =

assets/icons/reload.svg

Lines changed: 1 addition & 0 deletions
Loading

common/Cargo.toml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,10 @@ gst-rtsp = { package = "gstreamer-rtsp", version = "0.23.5", optional = true}
1818
gst-rtsp-server = { package = "gstreamer-rtsp-server", version = "0.23.5", optional = true}
1919

2020
gio = { version = "0.20.9", optional = true }
21-
m3u8-rs = { version = "6.0.0", optional = true }
2221
futures = { workspace = true, optional = true }
2322
tokio-stream = { workspace = true, optional = true }
2423
mdns-sd = { workspace = true, optional = true }
2524

26-
http = { path = "../http", optional = true }
2725
fcast-lib = { path = "../fcast-lib", optional = true }
2826
crossbeam-channel = { version = "0.5.15", optional = true }
2927

@@ -42,11 +40,5 @@ glutin_glx_sys = { version = "0.6.1", optional = true }
4240

4341
[features]
4442
video = ["gst", "gst-app", "gst-video", "gst-gl", "gst-gl-egl", "gst-gl-x11", "glutin_egl_sys", "glutin_glx_sys",]
45-
sender = ["gst", "gst-video", "gio", "m3u8-rs", "gst-pbutils",
46-
"http", "crossbeam-channel", "gst-app", "futures",
47-
"fcast-lib", "tokio-stream", "mdns-sd", "gst-gl-egl", "gst-gl-x11",
48-
"glutin_egl_sys", "glutin_glx_sys", "gst-rtsp", "gst-rtsp-server"]
49-
50-
[dev-dependencies]
51-
quickcheck = "1"
52-
quickcheck_macros = "1"
43+
sender = ["gst", "gst-video", "gio", "gst-pbutils", "crossbeam-channel", "gst-app", "futures",
44+
"fcast-lib", "tokio-stream", "mdns-sd", "gst-rtsp", "gst-rtsp-server",]

common/src/sender/audio.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// TODO: try pipewiredeviceprovider and use pulsedeviceprovider as fallback
2+
// TODO: monitor for changes
3+
#[cfg(target_os = "linux")]
4+
pub fn get_pulse_dev() -> anyhow::Result<gst::Device> {
5+
use anyhow::bail;
6+
use gst::prelude::*;
7+
8+
let provider = gst::DeviceProviderFactory::by_name("pulsedeviceprovider").ok_or(
9+
anyhow::anyhow!("Could not find pulse device provider factory"),
10+
)?;
11+
12+
provider.start()?;
13+
let devices = provider.devices();
14+
provider.stop();
15+
16+
for device in devices {
17+
if !device.has_classes("Audio/Sink") {
18+
continue;
19+
}
20+
let Some(props) = device.properties() else {
21+
continue;
22+
};
23+
if props.get::<bool>("is-default") == Ok(true) {
24+
return Ok(device);
25+
}
26+
}
27+
28+
bail!("No device found")
29+
}

common/src/sender/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[cfg(target_os = "linux")]
2+
pub mod audio;
13
pub mod discovery;
24
pub mod pipeline;
35
pub mod session;

common/src/sender/pipeline.rs

Lines changed: 72 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
#[cfg(target_os = "android")]
1919
use super::transmission::rtp::RtpSink;
20-
use super::transmission::{self, TransmissionSink, hls::HlsSink};
20+
use super::transmission::{self, TransmissionSink};
2121
use anyhow::Result;
2222
use fcast_lib::models::PlayMessage;
2323
#[cfg(target_os = "android")]
@@ -28,6 +28,7 @@ use log::error;
2828
#[cfg(target_os = "android")]
2929
use std::future::Future;
3030
use std::net::IpAddr;
31+
use std::str::FromStr;
3132

3233
pub use transmission::init;
3334

@@ -37,13 +38,19 @@ pub enum Event {
3738
Error,
3839
}
3940

41+
pub enum SourceConfig {
42+
AudioVideo {
43+
video: gst::Element,
44+
audio: gst::Element,
45+
},
46+
Video(gst::Element),
47+
Audio(gst::Element),
48+
}
49+
4050
#[cfg(not(target_os = "android"))]
4151
pub struct Pipeline {
4252
inner: gst::Pipeline,
43-
tx_sink: Option<Box<dyn TransmissionSink>>,
44-
tee: gst::Element,
45-
preview_queue: gst::Element,
46-
preview_appsink: gst::Element,
53+
tx_sink: Box<dyn TransmissionSink>,
4754
}
4855

4956
#[cfg(target_os = "android")]
@@ -171,58 +178,69 @@ impl Pipeline {
171178
})
172179
}
173180

181+
fn setup_video_source(pipeline: &gst::Pipeline, src: gst::Element) -> Result<gst::Element> {
182+
// TODO: needed?
183+
let videoflip = gst::ElementFactory::make("videoflip")
184+
.property_from_str("video-direction", "auto")
185+
.build()?;
186+
let videorate = gst::ElementFactory::make("videorate")
187+
.property("skip-to-first", true)
188+
.build()?;
189+
let capsfilter = gst::ElementFactory::make("capsfilter")
190+
.property("caps", gst::Caps::from_str("video/x-raw,framerate=30/1")?)
191+
.build()?;
192+
193+
// pipeline.add_many([&src, &videorate, &capsfilter])?;
194+
// gst::Element::link_many([&src, &videorate, &capsfilter])?;
195+
196+
pipeline.add_many([&src, &videoflip, &videorate, &capsfilter])?;
197+
gst::Element::link_many([&src, &videoflip, &videorate, &capsfilter])?;
198+
199+
Ok(capsfilter)
200+
}
201+
202+
fn setup_audio_source(pipeline: &gst::Pipeline, src: gst::Element) -> Result<gst::Element> {
203+
let capsfilter = gst::ElementFactory::make("capsfilter")
204+
.property(
205+
"caps",
206+
gst::Caps::from_str("audio/x-raw,channels=2,rate=48000")?,
207+
)
208+
.build()?;
209+
210+
pipeline.add_many([&src, &capsfilter])?;
211+
gst::Element::link_many([&src, &capsfilter])?;
212+
213+
Ok(capsfilter)
214+
}
215+
174216
#[cfg(not(target_os = "android"))]
175-
pub fn new<E, S>(preview_appsink: gst::Element, mut on_event: E, on_sources: S) -> Result<Self>
217+
pub fn new_rtsp<E>(mut on_event: E, source: SourceConfig) -> Result<Self>
176218
where
177219
E: FnMut(Event) + Send + Clone + 'static,
178-
S: Fn(&[gst::glib::Value]) -> Option<gst::glib::Value> + Send + Sync + 'static,
179220
{
180-
let scapsrc = gst::ElementFactory::make("scapsrc")
181-
.property("perform-internal-preroll", true)
182-
.build()?;
183-
let tee = gst::ElementFactory::make("tee").build()?;
184-
let preview_queue = gst::ElementFactory::make("queue")
185-
.name("preview_queue")
186-
.property("max-size-time", 0u64)
187-
.property("max-size-buffers", 0u32)
188-
.property("max-size-bytes", 0u32)
189-
.property_from_str("leaky", "downstream")
190-
.property("silent", true) // Don't emit signals, can give better perf.
191-
.build()?;
221+
use crate::sender::transmission::rtsp::RtspSink;
192222

193223
let pipeline = gst::Pipeline::new();
194224

195-
let tx_sink = None::<Box<dyn TransmissionSink>>;
196-
197-
// https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/3993
198-
scapsrc.static_pad("src").unwrap().add_probe(
199-
gst::PadProbeType::QUERY_UPSTREAM.union(gst::PadProbeType::PUSH),
200-
|_pad, info| match info.query_mut().map(|query| query.view_mut()) {
201-
Some(gst::QueryViewMut::Latency(latency)) => {
202-
let (_live, min, max) = latency.result();
203-
latency.set(false, min, max);
204-
gst::PadProbeReturn::Handled
205-
}
206-
_ => gst::PadProbeReturn::Pass,
225+
let source = match source {
226+
SourceConfig::AudioVideo { video, audio } => SourceConfig::AudioVideo {
227+
video: Self::setup_video_source(&pipeline, video)?,
228+
audio: Self::setup_audio_source(&pipeline, audio)?,
207229
},
208-
);
209-
210-
scapsrc.connect("select-source", false, on_sources);
211-
212-
pipeline.add_many([&scapsrc, &tee, &preview_queue, &preview_appsink])?;
213-
gst::Element::link_many([&scapsrc, &tee])?;
214-
gst::Element::link_many([&preview_queue, &preview_appsink])?;
230+
SourceConfig::Video(video) => {
231+
SourceConfig::Video(Self::setup_video_source(&pipeline, video)?)
232+
}
233+
SourceConfig::Audio(audio) => {
234+
SourceConfig::Audio(Self::setup_audio_source(&pipeline, audio)?)
235+
}
236+
};
215237

216-
let tee_preview_pad = tee
217-
.request_pad_simple("src_%u")
218-
.map_or_else(|| Err(anyhow::anyhow!("`request_pad_simple()` failed")), Ok)?;
219-
let queue_preview_pad = preview_queue
220-
.static_pad("sink")
221-
.ok_or(anyhow::anyhow!("preview_queue is missing static sink pad"))?;
222-
tee_preview_pad.link(&queue_preview_pad)?;
238+
let rtsp = RtspSink::new(&pipeline, source, 3000)?;
239+
let p = Self {
240+
inner: pipeline.clone(),
241+
tx_sink: Box::new(rtsp),
242+
};
223243

224-
// Start the pipeline in background thread because `scapsrc` initialization will block until
225-
// the user selects the input source.
226244
let _ = std::thread::spawn({
227245
let bus = pipeline
228246
.bus()
@@ -236,9 +254,11 @@ impl Pipeline {
236254
debug!("Failed to upgrade pipeline before starting");
237255
return;
238256
};
239-
debug!("Starting pipeline");
257+
debug!("Starting pipeline...");
240258
if let Err(err) = pipeline.set_state(gst::State::Playing) {
241259
error!("Failed to start pipeline: {err}");
260+
} else {
261+
debug!("Pipeline started");
242262
}
243263
}
244264

@@ -278,20 +298,11 @@ impl Pipeline {
278298
}
279299
});
280300

281-
Ok(Self {
282-
inner: pipeline,
283-
tx_sink,
284-
tee,
285-
preview_queue,
286-
preview_appsink,
287-
})
301+
Ok(p)
288302
}
289303

290304
pub fn playing(&mut self) -> Result<()> {
291-
match &mut self.tx_sink {
292-
Some(sink) => sink.playing(),
293-
None => Ok(()),
294-
}
305+
self.tx_sink.playing()
295306
}
296307

297308
#[cfg(target_os = "android")]
@@ -308,27 +319,7 @@ impl Pipeline {
308319
#[cfg(not(target_os = "android"))]
309320
pub fn shutdown(&mut self) -> Result<()> {
310321
self.inner.set_state(gst::State::Null)?;
311-
312-
self.preview_queue.unlink(&self.preview_appsink);
313-
self.inner.remove(&self.preview_appsink)?;
314-
315-
if let Some(sink) = &mut self.tx_sink {
316-
sink.shutdown();
317-
}
318-
319-
Ok(())
320-
}
321-
322-
#[cfg(not(target_os = "android"))]
323-
pub fn add_hls_sink(&mut self, port: u16) -> Result<()> {
324-
let tee_pad = self
325-
.tee
326-
.request_pad_simple("src_%u")
327-
.ok_or(anyhow::anyhow!("`request_pad_simple()` failed"))?;
328-
let hls = HlsSink::new(&self.inner, tee_pad, port)?;
329-
self.tx_sink = Some(Box::new(hls));
330-
331-
debug!("Added HLS sink");
322+
self.tx_sink.shutdown();
332323

333324
Ok(())
334325
}
@@ -348,20 +339,6 @@ impl Pipeline {
348339
Ok(())
349340
}
350341

351-
#[cfg(not(target_os = "android"))]
352-
pub fn add_rtsp_sink(&mut self, port: u16) -> Result<()> {
353-
let tee_pad = self
354-
.tee
355-
.request_pad_simple("src_%u")
356-
.ok_or(anyhow::anyhow!("`request_pad_simple()` failed"))?;
357-
let rtsp = transmission::rtsp::RtspSink::new(tee_pad, &self.inner, port)?;
358-
self.tx_sink = Some(Box::new(rtsp));
359-
360-
debug!("Added RTSP sink");
361-
362-
Ok(())
363-
}
364-
365342
#[cfg(target_os = "android")]
366343
pub fn add_rtp_sink(&mut self, port: u16, receiver_addr: IpAddr) -> Result<()> {
367344
let appsrc_pad = self
@@ -376,26 +353,9 @@ impl Pipeline {
376353
Ok(())
377354
}
378355

379-
pub fn remove_transmission_sink(&mut self) -> Result<()> {
380-
if let Some(sink) = &mut self.tx_sink {
381-
sink.shutdown();
382-
sink.unlink(&self.inner)?;
383-
}
384-
385-
self.tx_sink = None;
386-
387-
debug!("Removed transmission sink");
388-
389-
Ok(())
390-
}
391-
392356
/// Get the message that should be sent to a receiver to consume the stream if a transmission
393357
/// sink is present
394358
pub fn get_play_msg(&self, addr: IpAddr) -> Option<PlayMessage> {
395-
if let Some(sink) = &self.tx_sink {
396-
sink.get_play_msg(addr)
397-
} else {
398-
None
399-
}
359+
self.tx_sink.get_play_msg(addr)
400360
}
401361
}

0 commit comments

Comments
 (0)