mic_meter/
mic_meter.rs

1use anyhow::{Context, Result};
2use ffmpeg_sidecar::{
3  command::FfmpegCommand,
4  event::{FfmpegEvent, LogLevel},
5};
6use 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.
10pub 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}