Feat: Add replay-gain to audio playback

The `RecordingView` struct now includes `gain_db` to represent
replay-gain. This value is read from the recording's metadata (`.json`
sidecar) and passed to the frontend.

The JavaScript in `case_recordings.html` uses this `data-gain-db`
attribute to apply replay-gain using the Web Audio API. This ensures
consistent playback loudness across different recordings. The
implementation uses a shared `AudioContext` to manage resources
efficiently and handles cases where the browser might not support
`AudioContext`.

Additionally, the audio recording on Wear OS now uses
`MediaRecorder.AudioSource.VOICE_RECOGNITION` instead of `MIC`. This
leverages platform noise and echo cancellation, aiming for a cleaner
signal before server-side gain adjustment.
This commit is contained in:
2026-04-27 17:08:10 +02:00
parent 710b60bb16
commit 99f77b666d
3 changed files with 114 additions and 2 deletions
+69
View File
@@ -37,6 +37,13 @@ pub(crate) struct RecordingView {
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
/// after a non-recoverable ffmpeg or whisper error.
pub(crate) failed: bool,
/// Replay-gain in dB to apply on the client. Pulled from
/// `<stem>.json`'s `loudness.gain_db`. `None` means no gain — pre-
/// loudness recordings, silent files (`-inf` measurement), or
/// recordings whose analyze step failed. The template renders this
/// as `data-gain-db` on the player and the JS multiplies the audio
/// signal accordingly via a Web Audio `GainNode`.
pub(crate) gain_db: Option<f64>,
}
pub async fn handle_audio(
@@ -173,6 +180,7 @@ struct RawRecording {
failed: bool,
transcript: TranscriptState,
duration_seconds: Option<u32>,
gain_db: Option<f64>,
audio_path: PathBuf,
}
@@ -200,6 +208,10 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
let stem_path = case_dir.join(stem_file);
let meta = paths::read_recording_meta(&stem_path).await;
let duration_seconds = meta.as_ref().and_then(|m| m.duration_seconds);
let gain_db = meta
.as_ref()
.and_then(|m| m.loudness.as_ref())
.map(|l| l.gain_db);
let transcript = TranscriptState::from_meta(meta.as_ref());
let audio_path = case_dir.join(&filename);
@@ -208,6 +220,7 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
failed,
transcript,
duration_seconds,
gain_db,
audio_path,
});
}
@@ -249,6 +262,7 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
duration_seconds: r.duration_seconds,
transcript: r.transcript,
failed: r.failed,
gain_db: r.gain_db,
}
})
.collect();
@@ -330,6 +344,61 @@ mod tests {
assert!(parse_range("bytes=-0", 1000).is_none());
}
/// Recording with a `loudness` block in its sidecar surfaces
/// `gain_db` on the `RecordingView`. Seeding a full
/// `RecordingMeta` directly via serde_json instead of going
/// through `write_recording_meta_sync`, since that helper does
/// not (yet) carry a loudness parameter.
#[tokio::test]
async fn scan_reads_gain_db_from_loudness() {
let dir = tempdir().unwrap();
let stem = "2026-04-19T13-00-00Z";
tokio::fs::write(dir.path().join(format!("{stem}.m4a")), b"audio")
.await
.unwrap();
let meta = doctate_common::RecordingMeta {
transcript: doctate_common::Transcript::Content { text: "hi".into() },
duration_seconds: Some(30),
loudness: Some(doctate_common::Loudness {
mean_db: -22.0,
max_db: -15.0,
gain_db: 6.0,
}),
};
let bytes = serde_json::to_vec(&meta).unwrap();
tokio::fs::write(dir.path().join(format!("{stem}.json")), bytes)
.await
.unwrap();
let got = scan_recordings(dir.path()).await;
assert_eq!(got.len(), 1);
assert_eq!(got[0].gain_db, Some(6.0));
assert_eq!(got[0].duration_seconds, Some(30));
}
/// Pre-loudness sidecars (worker wrote meta before this feature
/// existed) carry no `loudness` field, deserialize as `None`
/// (thanks to `#[serde(default)]`), and surface `gain_db: None`
/// on the view. The frontend then renders `data-gain-db="0"` and
/// the player skips the Web-Audio routing.
#[tokio::test]
async fn scan_yields_none_gain_db_for_meta_without_loudness() {
let dir = tempdir().unwrap();
tokio::fs::write(dir.path().join("2026-04-19T14-00-00Z.m4a"), b"audio")
.await
.unwrap();
crate::paths::write_recording_meta_sync(
dir.path(),
"2026-04-19T14-00-00Z",
doctate_common::Transcript::Silent,
Some(7),
);
let got = scan_recordings(dir.path()).await;
assert_eq!(got.len(), 1);
assert_eq!(got[0].gain_db, None);
}
#[tokio::test]
async fn scan_handles_malformed_meta_sidecar() {
let dir = tempdir().unwrap();