From 99f77b666d4bfc57d16238cab142659acfc70ca6 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 27 Apr 2026 17:08:10 +0200 Subject: [PATCH] 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. --- server/src/routes/web.rs | 69 +++++++++++++++++++ server/templates/case_recordings.html | 41 ++++++++++- .../com/doctate/watch/audio/AudioRecorder.kt | 6 +- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index e0b3928..cbbf433 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -37,6 +37,13 @@ pub(crate) struct RecordingView { /// True if the file has been renamed to `.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 + /// `.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, } pub async fn handle_audio( @@ -173,6 +180,7 @@ struct RawRecording { failed: bool, transcript: TranscriptState, duration_seconds: Option, + gain_db: Option, audio_path: PathBuf, } @@ -200,6 +208,10 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { 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 { failed, transcript, duration_seconds, + gain_db, audio_path, }); } @@ -249,6 +262,7 @@ pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec { 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(); diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html index 061005d..114dd15 100644 --- a/server/templates/case_recordings.html +++ b/server/templates/case_recordings.html @@ -143,7 +143,7 @@ try {
-
+
0:00 / 0:00 @@ -247,6 +247,25 @@ try { return `${m}:${sec.toString().padStart(2, '0')}`; }; + // Shared AudioContext for replay-gain. Lazy-created on the first + // player that actually needs gain (Safari requires user-gesture + // creation); shared across all players to avoid hitting the + // per-page context limit. `null` if the browser does not expose + // an AudioContext at all — falls back to the plain