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
+40 -1
View File
@@ -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();