Skip to content

Commit 03a4922

Browse files
docs: mic meter demo (#64)
* feat: scaffold audio waveform demo [WIP] * fix: prevent newlines from creating empty log events * test: cover empty events from processing newlines * fix: handle audio output chunks * feat: finish mic meter demo * refactor: rename example "terminal" => "terminal_video" * fix: typo in doc comment
1 parent 694d534 commit 03a4922

File tree

5 files changed

+185
-16
lines changed

5 files changed

+185
-16
lines changed

examples/mic_meter.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
use anyhow::{Context, Result};
2+
use ffmpeg_sidecar::{
3+
command::FfmpegCommand,
4+
event::{FfmpegEvent, LogLevel},
5+
};
6+
use std::{cmp::max, iter::repeat};
7+
8+
/// Process microphone audio data in realtime and display a volume meter/level
9+
/// indicator rendered to the terminal.
10+
pub fn main() -> Result<()> {
11+
if cfg!(not(windows)) {
12+
eprintln!("Note: Methods for capturing audio are platform-specific and this demo is intended for Windows.");
13+
eprintln!("On Linux or Mac, you need to switch from the `dshow` format to a different one supported on your platform.");
14+
eprintln!("Make sure to also include format-specific arguments such as `-audio_buffer_size`.");
15+
eprintln!("Pull requests are welcome to make this demo cross-platform!");
16+
}
17+
18+
// First step: find default audio input device
19+
// Runs an `ffmpeg -list_devices` command and selects the first one found
20+
// Sample log output: [dshow @ 000001c9babdb000] "Headset Microphone (Arctis 7 Chat)" (audio)
21+
22+
let audio_device = FfmpegCommand::new()
23+
.hide_banner()
24+
.args(&["-list_devices", "true"])
25+
.format("dshow")
26+
.input("dummy")
27+
.spawn()?
28+
.iter()?
29+
.into_ffmpeg_stderr()
30+
.find(|line| line.contains("(audio)"))
31+
.map(|line| line.split('\"').nth(1).map(|s| s.to_string()))
32+
.context("No audio device found")?
33+
.context("Failed to parse audio device")?;
34+
35+
println!("Listening to audio device: {}", audio_device);
36+
37+
// Second step: Capture audio and analyze w/ `ebur128` audio filter
38+
// Loudness metadata will be printed to the FFmpeg logs
39+
// Docs: <https://ffmpeg.org/ffmpeg-filters.html#ebur128-1>
40+
41+
let iter = FfmpegCommand::new()
42+
.format("dshow")
43+
.args("-audio_buffer_size 50".split(' ')) // reduces latency to 50ms (dshow-specific)
44+
.input(format!("audio={audio_device}"))
45+
.args("-af ebur128=metadata=1,ametadata=print".split(' '))
46+
.format("null")
47+
.output("-")
48+
.spawn()?
49+
.iter()?;
50+
51+
// Note: even though the audio device name may have spaces, it should *not* be
52+
// in quotes (""). Quotes are only needed on the command line to separate
53+
// different arguments. Since Rust invokes the command directly without a
54+
// shell interpreter, args are already divided up correctly. Any quotes
55+
// would be included in the device name instead and the command would fail.
56+
// <https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/issues/648#issuecomment-866242144>
57+
58+
let mut first_volume_event = true;
59+
for event in iter {
60+
match event {
61+
FfmpegEvent::Error(e) | FfmpegEvent::Log(LogLevel::Error | LogLevel::Fatal, e) => {
62+
eprintln!("{e}");
63+
}
64+
FfmpegEvent::Log(LogLevel::Info, msg) if msg.contains("lavfi.r128.M=") => {
65+
if let Some(volume) = msg.split("lavfi.r128.M=").last() {
66+
// Sample log output: [Parsed_ametadata_1 @ 0000024c27effdc0] [info] lavfi.r128.M=-120.691
67+
// M = "momentary loudness"; a sliding time window of 400ms
68+
// Volume scale is roughly -70 to 0 LUFS. Anything below -70 is silence.
69+
// See <https://en.wikipedia.org/wiki/EBU_R_128#Metering>
70+
let volume_f32 = volume.parse::<f32>().context("Failed to parse volume")?;
71+
let volume_normalized: usize = max(((volume_f32 / 5.0).round() as i32) + 14, 0) as usize;
72+
let volume_percent = ((volume_normalized as f32 / 14.0) * 100.0).round();
73+
74+
// Clear previous line of output
75+
if !first_volume_event {
76+
print!("\x1b[1A\x1b[2K");
77+
} else {
78+
first_volume_event = false;
79+
}
80+
81+
// Blinking red dot to indicate recording
82+
let time = std::time::SystemTime::now()
83+
.duration_since(std::time::UNIX_EPOCH)
84+
.unwrap()
85+
.as_secs();
86+
let recording_indicator = if time % 2 == 0 { "🔴" } else { " " };
87+
88+
println!(
89+
"{} {} {}%",
90+
recording_indicator,
91+
repeat('█').take(volume_normalized).collect::<String>(),
92+
volume_percent
93+
);
94+
}
95+
}
96+
_ => {}
97+
}
98+
}
99+
100+
Ok(())
101+
}
File renamed without changes.

src/iter.rs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,27 +188,32 @@ pub fn spawn_stdout_thread(
188188
) -> JoinHandle<()> {
189189
std::thread::spawn(move || {
190190
// Filter streams which are sent to stdout
191-
let stdout_output_video_streams = output_streams
192-
.iter()
193-
.filter(|stream| stream.is_video())
194-
.filter(|stream| {
195-
outputs
196-
.get(stream.parent_index as usize)
197-
.map(|o| o.is_stdout())
198-
.unwrap_or(false)
199-
});
191+
let stdout_streams = output_streams.iter().filter(|stream| {
192+
outputs
193+
.get(stream.parent_index as usize)
194+
.map(|o| o.is_stdout())
195+
.unwrap_or(false)
196+
});
200197

201198
// Exit early if nothing is being sent to stdout
202-
if stdout_output_video_streams.clone().count() == 0 {
199+
if stdout_streams.clone().count() == 0 {
200+
tx.send(FfmpegEvent::Error("No streams found".to_owned()))
201+
.ok();
203202
return;
204203
}
205204

206205
// If the size of a frame can't be determined, it will be read in arbitrary chunks.
207206
let mut chunked_mode = false;
208207

208+
// Immediately default to chunked mode for non-video streams
209+
let stdout_video_streams = stdout_streams.clone().filter(|stream| stream.is_video());
210+
if stdout_video_streams.clone().count() == 0 {
211+
chunked_mode = true;
212+
}
213+
209214
// Calculate frame buffer sizes up front.
210215
// Any sizes that cannot be calculated will trigger chunked mode.
211-
let frame_buffer_sizes: Vec<usize> = stdout_output_video_streams
216+
let frame_buffer_sizes: Vec<usize> = stdout_video_streams
212217
.clone()
213218
.map(|video_stream| {
214219
// Any non-rawvideo streams instantly enable chunked mode, since it's
@@ -240,7 +245,7 @@ pub fn spawn_stdout_thread(
240245
// but we can only keep track of them if the framerates match. It's
241246
// theoretically still possible to determine the expected frame order,
242247
// but it's not currently supported.
243-
let output_framerates: Vec<f32> = stdout_output_video_streams
248+
let output_framerates: Vec<f32> = stdout_video_streams
244249
.clone()
245250
.filter(|s| s.format == "rawvideo")
246251
.map(|video_stream| {

src/read_until_any.rs

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22
33
use std::io::{BufRead, ErrorKind, Result};
44

5-
/// `BufRead::read_until` with multiple delimiters.
5+
/// Reads from the provided buffer until any of the delimiter bytes match.
6+
/// The output buffer will include the ending delimiter.
7+
/// Also skips over zero-length reads.
8+
/// See [`BufRead::read_until`](https://doc.rust-lang.org/std/io/trait.BufRead.html#method.read_until).
69
pub fn read_until_any<R: BufRead + ?Sized>(
710
r: &mut R,
811
delims: &[u8],
@@ -17,10 +20,21 @@ pub fn read_until_any<R: BufRead + ?Sized>(
1720
Err(e) => return Err(e),
1821
};
1922

23+
let start_delims = if read == 0 {
24+
available
25+
.iter()
26+
.take_while(|&&b| delims.iter().any(|&d| d == b))
27+
.count()
28+
} else {
29+
0
30+
};
31+
2032
// NB: `memchr` crate would be faster, but it's unstable and not worth the dependency.
2133
let first_delim_index = available
2234
.iter()
23-
.position(|b| delims.iter().any(|d| *d == *b));
35+
.skip(start_delims)
36+
.position(|&b| delims.iter().any(|&d| d == b))
37+
.map(|i| i + start_delims);
2438

2539
match first_delim_index {
2640
Some(i) => {
@@ -35,7 +49,17 @@ pub fn read_until_any<R: BufRead + ?Sized>(
3549
};
3650
r.consume(used);
3751
read += used;
38-
if done || used == 0 {
52+
53+
if done {
54+
return Ok(read);
55+
}
56+
57+
// Discard final trailing delimiters
58+
if used == 0 && buf.iter().all(|&b| delims.iter().any(|&d| d == b)) {
59+
return Ok(0);
60+
}
61+
62+
if used == 0 {
3963
return Ok(read);
4064
}
4165
}

src/test.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ fn test_chunks() {
227227
}
228228

229229
#[test]
230-
fn test_chunks_with_audio() {
230+
fn test_chunks_with_video_and_audio() {
231231
let mut chunks = 0;
232232
let mut frames = 0;
233233

@@ -248,6 +248,26 @@ fn test_chunks_with_audio() {
248248
});
249249

250250
assert!(chunks > 0);
251+
assert!(frames == 0);
252+
}
253+
254+
#[test]
255+
fn test_chunks_with_audio_only() -> anyhow::Result<()> {
256+
let chunks = FfmpegCommand::new()
257+
.args("-f lavfi -i sine=frequency=1000:duration=10".split(' '))
258+
.format("s16le")
259+
.args(&["-ac", "1"]) // Mono audio
260+
.codec_audio("pcm_s16le")
261+
.args(&["-ar", "44100"]) // Sample rate 44.1kHz
262+
.pipe_stdout()
263+
.spawn()?
264+
.iter()?
265+
.filter(|e| matches!(e, FfmpegEvent::OutputChunk(_)))
266+
.count();
267+
268+
assert!(chunks > 0);
269+
270+
Ok(())
251271
}
252272

253273
#[test]
@@ -676,3 +696,22 @@ fn test_stdout_interleaved_frames_fallback() -> anyhow::Result<()> {
676696

677697
Ok(())
678698
}
699+
700+
/// Make sure consecutive new lines in logs don't result in empty events.
701+
#[test]
702+
fn test_no_empty_events() -> anyhow::Result<()> {
703+
let empty_events = FfmpegCommand::new()
704+
.testsrc()
705+
.rawvideo()
706+
.spawn()?
707+
.iter()?
708+
.filter(|event| match event {
709+
FfmpegEvent::Log(_, msg) if msg.is_empty() => true,
710+
_ => false,
711+
})
712+
.count();
713+
714+
assert!(empty_events == 0);
715+
716+
Ok(())
717+
}

0 commit comments

Comments
 (0)