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:
@@ -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();
|
||||
|
||||
@@ -143,7 +143,7 @@ try {
|
||||
<div class="recording{% if rec.failed %} failed-row{% endif %}">
|
||||
<div class="recording-head">
|
||||
<time class="rec-time" datetime="{{ rec.recorded_at_iso }}">{{ rec.recorded_at_iso }}</time>
|
||||
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}>
|
||||
<div class="player" data-src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}"{% match rec.duration_seconds %}{% when Some with (d) %} data-duration="{{ d }}"{% when None %}{% endmatch %}{% match rec.gain_db %}{% when Some with (g) %} data-gain-db="{{ g }}"{% when None %}{% endmatch %}>
|
||||
<button type="button" class="play" aria-label="Abspielen"></button>
|
||||
<input type="range" class="seek" min="0" max="1000" value="0" step="1" aria-label="Position">
|
||||
<span class="time">0:00 / 0:00</span>
|
||||
@@ -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 <audio> path.
|
||||
let sharedCtx = null;
|
||||
const ensureCtx = () => {
|
||||
if (!sharedCtx) {
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (Ctx) sharedCtx = new Ctx();
|
||||
}
|
||||
if (sharedCtx && sharedCtx.state === 'suspended') {
|
||||
// Some Safari builds keep the context suspended even when
|
||||
// created from a user gesture. Resume is a no-op when running.
|
||||
sharedCtx.resume();
|
||||
}
|
||||
return sharedCtx;
|
||||
};
|
||||
|
||||
document.querySelectorAll('.player').forEach(p => {
|
||||
const src = p.dataset.src;
|
||||
const btn = p.querySelector('.play');
|
||||
@@ -287,6 +306,26 @@ try {
|
||||
if (audio) return audio;
|
||||
audio = new Audio(src);
|
||||
audio.preload = 'metadata';
|
||||
|
||||
// Replay-gain: route through Web Audio with a GainNode so the
|
||||
// recording plays at the consistent target loudness regardless
|
||||
// of which client recorded it. `createMediaElementSource`
|
||||
// *redirects* the audio element's output through the graph —
|
||||
// forgetting the `connect(ctx.destination)` would silence the
|
||||
// player. Skipped entirely when gain is 0, missing, or
|
||||
// negative (older meta sidecars, silent files, analyze errors)
|
||||
// so the default <audio> path still works.
|
||||
const gainDb = parseFloat(p.dataset.gainDb);
|
||||
if (isFinite(gainDb) && gainDb > 0) {
|
||||
const ctx = ensureCtx();
|
||||
if (ctx) {
|
||||
const mediaSrc = ctx.createMediaElementSource(audio);
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = Math.pow(10, gainDb / 20);
|
||||
mediaSrc.connect(gainNode).connect(ctx.destination);
|
||||
}
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
if (isFinite(audio.duration) && audio.duration > 0) duration = audio.duration;
|
||||
tryApplyPendingSeek();
|
||||
|
||||
@@ -26,7 +26,11 @@ class AudioRecorder(private val context: Context) {
|
||||
require(recorder == null) { "recorder already active" }
|
||||
outFile.parentFile?.takeIf { !it.exists() }?.mkdirs()
|
||||
val r = newRecorderInstance().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
// VOICE_RECOGNITION lowers raw level vs MIC but applies the
|
||||
// platform's noise/echo cleaning. Combined with the
|
||||
// server-side replay-gain pass the playback ends up at
|
||||
// target loudness with cleaner SNR than raw MIC + post-gain.
|
||||
setAudioSource(MediaRecorder.AudioSource.VOICE_RECOGNITION)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioSamplingRate(16_000)
|
||||
|
||||
Reference in New Issue
Block a user