Add loudness data to recording metadata
This commit introduces the `Loudness` struct to store replay-gain data (mean, max, and calculated gain in dB) derived from `ffmpeg`'s `volumedetect`. The `RecordingMeta` struct is updated to include an optional `loudness` field. This allows for consistent playback volume across recordings with varying microphone levels by applying gain in the browser. Additionally, new `server/src/loudness.rs` and `server/src/transcribe/ffmpeg.rs` modules are added. The former defines constants for replay-gain targets and logic to compute gain, while the latter handles audio analysis using `ffmpeg` to extract loudness metrics. The `#[serde(default)]` attribute on the `loudness` field in `RecordingMeta` ensures backward compatibility with older metadata files that do not contain this field. Tests are included to verify round-trip serialization and parsing of older formats.
This commit is contained in:
@@ -109,3 +109,175 @@ pub async fn probe_duration_seconds(path: &Path) -> Result<u32, FfmpegError> {
|
||||
}
|
||||
Ok(secs.round() as u32)
|
||||
}
|
||||
|
||||
/// Result of one `ffmpeg -af volumedetect` pass on an audio file:
|
||||
/// duration plus mean and peak volume in a single decode.
|
||||
///
|
||||
/// `mean_db` and `max_db` may be `f64::NEG_INFINITY` for fully silent
|
||||
/// (zero-sample) input — `f64::from_str` accepts `"-inf"` natively.
|
||||
/// Callers that build a `Loudness` value must gate on `is_finite()`
|
||||
/// to avoid feeding `+Inf` gain into Web Audio.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AudioAnalysis {
|
||||
pub duration_seconds: u32,
|
||||
pub mean_db: f64,
|
||||
pub max_db: f64,
|
||||
}
|
||||
|
||||
/// Run `ffmpeg ... -af volumedetect -f null -` against `path` and
|
||||
/// extract duration plus mean/peak volume from its stderr in a single
|
||||
/// decode pass.
|
||||
///
|
||||
/// Decode-bound: O(audio length) wall-time, ~100-500 ms for typical
|
||||
/// 10-60 s dictations on one CPU core. Worker code overlaps this with
|
||||
/// Whisper via `tokio::join!`, so on the pipeline wallclock the cost
|
||||
/// is effectively hidden behind the Whisper call.
|
||||
pub async fn analyze_audio(path: &Path) -> Result<AudioAnalysis, FfmpegError> {
|
||||
let output = Command::new("ffmpeg")
|
||||
.arg("-hide_banner")
|
||||
.arg("-nostats")
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.arg("-af")
|
||||
.arg("volumedetect")
|
||||
.arg("-f")
|
||||
.arg("null")
|
||||
.arg("-")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await
|
||||
.map_err(FfmpegError::Spawn)?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(FfmpegError::NonZeroExit {
|
||||
code: output.status.code(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
parse_analysis(&stderr)
|
||||
}
|
||||
|
||||
/// Pure-string parser split out from [`analyze_audio`] so unit tests
|
||||
/// can target the parsing rules without spawning a real ffmpeg.
|
||||
fn parse_analysis(stderr: &str) -> Result<AudioAnalysis, FfmpegError> {
|
||||
let duration_seconds = parse_duration_seconds(stderr)
|
||||
.ok_or_else(|| FfmpegError::Parse("missing or unparseable Duration line".into()))?;
|
||||
let mean_db = parse_volume_db(stderr, "mean_volume")
|
||||
.ok_or_else(|| FfmpegError::Parse("missing or unparseable mean_volume line".into()))?;
|
||||
let max_db = parse_volume_db(stderr, "max_volume")
|
||||
.ok_or_else(|| FfmpegError::Parse("missing or unparseable max_volume line".into()))?;
|
||||
Ok(AudioAnalysis {
|
||||
duration_seconds,
|
||||
mean_db,
|
||||
max_db,
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate the first `Duration: HH:MM:SS.cs, ...` line in ffmpeg's
|
||||
/// stderr and convert it to whole rounded seconds.
|
||||
fn parse_duration_seconds(stderr: &str) -> Option<u32> {
|
||||
let line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("Duration: "))?;
|
||||
let timecode = line
|
||||
.trim_start()
|
||||
.strip_prefix("Duration: ")?
|
||||
.split(',')
|
||||
.next()?
|
||||
.trim();
|
||||
let mut parts = timecode.split(':');
|
||||
let hours: f64 = parts.next()?.parse().ok()?;
|
||||
let minutes: f64 = parts.next()?.parse().ok()?;
|
||||
let seconds: f64 = parts.next()?.parse().ok()?;
|
||||
let total = hours * 3600.0 + minutes * 60.0 + seconds;
|
||||
if !total.is_finite() || total < 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(total.round() as u32)
|
||||
}
|
||||
|
||||
/// Find a `<key>: <value> dB` entry in stderr and return `<value>` as
|
||||
/// `f64`. `f64::from_str` accepts `-inf` / `inf` natively, which is
|
||||
/// what we need for fully silent input.
|
||||
fn parse_volume_db(stderr: &str, key: &str) -> Option<f64> {
|
||||
let needle = format!("{key}: ");
|
||||
let line = stderr.lines().find(|l| l.contains(&needle))?;
|
||||
let after = &line[line.find(&needle)? + needle.len()..];
|
||||
let token = after.split_whitespace().next()?;
|
||||
token.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Realistic ffmpeg stderr for a successful volumedetect pass on a
|
||||
/// ~42-second mono dictation, trimmed to the lines our parser cares
|
||||
/// about. Format mirrors a real run; lifted verbatim modulo the
|
||||
/// memory-address tokens.
|
||||
const REALISTIC_STDERR: &str = "\
|
||||
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from '/tmp/x.m4a':
|
||||
Metadata:
|
||||
major_brand : mp42
|
||||
Duration: 00:00:42.31, start: 0.000000, bitrate: 64 kb/s
|
||||
Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 16000 Hz, mono, fltp, 64 kb/s
|
||||
[Parsed_volumedetect_0 @ 0x55] mean_volume: -27.3 dB
|
||||
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
|
||||
[Parsed_volumedetect_0 @ 0x55] histogram_0db: 5
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parse_realistic_stderr() {
|
||||
let got = parse_analysis(REALISTIC_STDERR).expect("Ok");
|
||||
assert_eq!(
|
||||
got,
|
||||
AudioAnalysis {
|
||||
duration_seconds: 42,
|
||||
mean_db: -27.3,
|
||||
max_db: -3.1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_file_yields_neg_infinity() {
|
||||
let stderr = "\
|
||||
Input #0, ...:
|
||||
Duration: 00:00:05.00, start: 0.000000, bitrate: 64 kb/s
|
||||
[Parsed_volumedetect_0 @ 0x55] mean_volume: -inf dB
|
||||
[Parsed_volumedetect_0 @ 0x55] max_volume: -inf dB
|
||||
";
|
||||
let got = parse_analysis(stderr).expect("Ok");
|
||||
assert_eq!(got.duration_seconds, 5);
|
||||
assert!(got.mean_db.is_infinite() && got.mean_db.is_sign_negative());
|
||||
assert!(got.max_db.is_infinite() && got.max_db.is_sign_negative());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_duration_is_parse_error() {
|
||||
let stderr = "\
|
||||
[Parsed_volumedetect_0 @ 0x55] mean_volume: -27.3 dB
|
||||
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
|
||||
";
|
||||
match parse_analysis(stderr) {
|
||||
Err(FfmpegError::Parse(_)) => {}
|
||||
other => panic!("expected Parse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_mean_volume_is_parse_error() {
|
||||
let stderr = "\
|
||||
Duration: 00:00:42.31, start: 0.000000, bitrate: 64 kb/s
|
||||
[Parsed_volumedetect_0 @ 0x55] max_volume: -3.1 dB
|
||||
";
|
||||
match parse_analysis(stderr) {
|
||||
Err(FfmpegError::Parse(_)) => {}
|
||||
other => panic!("expected Parse error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ pub async fn run(
|
||||
let meta = RecordingMeta {
|
||||
transcript,
|
||||
duration_seconds,
|
||||
loudness: None,
|
||||
};
|
||||
if let Err(e) = paths::write_recording_meta_atomic(&audio_path, &meta).await {
|
||||
error!(meta = %meta_path.display(), error = %e, "writing recording meta failed");
|
||||
|
||||
Reference in New Issue
Block a user