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:
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user