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:
2026-04-27 16:11:02 +02:00
parent 8a531144af
commit 427a28ac6b
7 changed files with 310 additions and 1 deletions
+62 -1
View File
@@ -51,12 +51,28 @@ pub enum Transcript {
Content { text: String },
}
/// Replay-gain data for one recording, derived from a single
/// `volumedetect` pass at the end of the transcribe pipeline.
///
/// `gain_db` is applied browser-side via a Web Audio `GainNode` so
/// recordings from clients with very different mic levels (Watch
/// ~-38 dB mean, desktop ~-27 dB) play back at a consistent loudness;
/// the audio file itself is never modified. `mean_db` and `max_db` are
/// persisted alongside so the gain target can be retuned later without
/// re-decoding the audio.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Loudness {
pub mean_db: f64,
pub max_db: f64,
pub gain_db: f64,
}
/// Per-recording metadata persisted as `<stem>.json`.
///
/// Single atomic write at the end of the transcribe pipeline. The
/// presence of this file is the canonical signal "the transcriber
/// finished for this recording". Absent file = still pending.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RecordingMeta {
/// Transcriber outcome. See [`Transcript`].
pub transcript: Transcript,
@@ -65,6 +81,11 @@ pub struct RecordingMeta {
/// flows that mint a metadata file without invoking ffprobe stay
/// valid; production worker writes always populate this.
pub duration_seconds: Option<u32>,
/// Replay-gain data. `None` for sidecars written before this field
/// existed and for production paths where `volumedetect` failed.
/// `#[serde(default)]` keeps older JSON without this field parsable.
#[serde(default)]
pub loudness: Option<Loudness>,
}
/// Three-way state of a recording's transcript, as seen by the server
@@ -145,6 +166,7 @@ mod tests {
let meta = RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: Some(7),
loudness: None,
};
assert_eq!(
TranscriptState::from_meta(Some(&meta)),
@@ -159,6 +181,7 @@ mod tests {
text: "Hallo".into(),
},
duration_seconds: None,
loudness: None,
};
assert_eq!(
TranscriptState::from_meta(Some(&meta)),
@@ -207,6 +230,7 @@ mod tests {
text: "Befund.".into(),
},
duration_seconds: Some(42),
loudness: None,
};
let json = serde_json::to_string(&meta).unwrap();
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
@@ -218,10 +242,47 @@ mod tests {
let meta = RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: None,
loudness: None,
};
let json = serde_json::to_string(&meta).unwrap();
assert!(json.contains(r#""duration_seconds":null"#));
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
#[test]
fn recording_meta_roundtrip_with_loudness() {
let meta = RecordingMeta {
transcript: Transcript::Content {
text: "Befund.".into(),
},
duration_seconds: Some(42),
loudness: Some(Loudness {
mean_db: -27.3,
max_db: -3.1,
gain_db: 2.1,
}),
};
let json = serde_json::to_string(&meta).unwrap();
let back: RecordingMeta = serde_json::from_str(&json).unwrap();
assert_eq!(back, meta);
}
/// Backwards-compat: a sidecar written before `loudness` existed
/// must still parse, with the field defaulting to `None`. This is
/// what `#[serde(default)]` on the field guarantees and is the only
/// reason we don't need a migration step for pre-loudness recordings.
#[test]
fn old_meta_without_loudness_parses_as_none() {
let json = r#"{"transcript":{"state":"silent"},"duration_seconds":7}"#;
let meta: RecordingMeta = serde_json::from_str(json).unwrap();
assert_eq!(
meta,
RecordingMeta {
transcript: Transcript::Silent,
duration_seconds: Some(7),
loudness: None,
}
);
}
}