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