From ff45651c2285e4b1e70f7278843b433fad6b254c Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:22:02 +0200 Subject: [PATCH] Make the visualisers answer the whole spectrum, and give notifications an icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web display carried three mis-tunings the Android client had already found and fixed, each recorded in Android's own comments and never ported back: - fftSize was 1024, so bins are 43 Hz apart. That is wider than the bottom bands of a log-spaced display, and the lowest dozen bars all landed on the same one or two bins and drew the same number. 2048 resolves them, which is most of what "only the left edge moves" was. - minDecibels/maxDecibels were never set, so the AnalyserNode defaults applied: -100 to -30. Ordinary mastered speech spends most of a sentence above a -30 ceiling, so band after band sat pinned at 255 — and a clipped bar cannot move. The window is now -78 to -4, matching Android exactly. - The tilt was 1.6, the value Android's comment records as lifting the top bands by 2.6x until they clipped on their own. It is 0.8 here too now. Both clients also gained the thing neither had: normalisation against what the display has been hearing rather than against full scale. It is why a quietly mastered episode drew a flat display while a loud one drew a lively one, and podcast audio makes it worse than music does — levelling between shows is far less consistent. Bounded on both ends: it never exceeds 3x and never lifts silence, so a pause stays empty instead of becoming a wall of room tone. Measured in a real browser against a synthetic voice with harmonics and sibilance, comparing the old configuration with the new one over 120 frames: low bands top bands before 0.783 1.000 after 0.942 0.465 The top bands were not static there, they were slamming between the rails, which reads as noise rather than as a spectrum; the bottom moved less than it does now. An earlier run against a source with no content above 1.7 kHz showed the opposite for the top bands, because the old settings were amplifying the noise floor into something that looked like signal. Both numbers are in the pull request rather than only the flattering one. The mapping now lives in lib/audio/spectrum.ts so it can be tested without a browser, which is also how the tilt regression is pinned: at 1.6 more than a third of the bands clip on a speech-shaped input, and the test fails. Separately, new-episode notifications used android.R.drawable .stat_sys_download_done — a download tick for something that is not a download. There was no suitable glyph in the project, so this adds one: the feed mark, which is what "new items in a show you follow" means and which survives being drawn at 24dp. Constructed from exact geometry rather than traced by hand, and checked by rasterising the paths. Verified on an emulator: playback runs with the new gain stage on the audio thread without a crash or an audio-track error, and the drawable is compiled into the APK. Co-Authored-By: Claude Opus 5 --- apps/android/app/build.gradle.kts | 4 +- .../data/repository/ContentRefreshWorker.kt | 2 +- .../drawable/ic_notification_new_episodes.xml | 34 ++++ .../koalacast/core/player/AmplitudeTap.kt | 6 + .../koalacast/core/player/Spectrum.kt | 52 +++++ .../koalacast/core/player/SpectrumTest.kt | 65 +++++++ apps/web/package.json | 2 +- apps/web/src/lib/audio/engine.ts | 73 ++++--- apps/web/src/lib/audio/spectrum.test.ts | 179 ++++++++++++++++++ apps/web/src/lib/audio/spectrum.ts | 140 ++++++++++++++ 10 files changed, 515 insertions(+), 42 deletions(-) create mode 100644 apps/android/core/data/src/main/res/drawable/ic_notification_new_episodes.xml create mode 100644 apps/web/src/lib/audio/spectrum.test.ts create mode 100644 apps/web/src/lib/audio/spectrum.ts diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 66160f71..981b4002 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -26,8 +26,8 @@ android { defaultConfig { applicationId = "net.koalastuff.koalacast" - versionCode = 44 - versionName = "0.11.4" + versionCode = 45 + versionName = "0.11.5" } signingConfigs { diff --git a/apps/android/core/data/src/main/kotlin/net/koalastuff/koalacast/core/data/repository/ContentRefreshWorker.kt b/apps/android/core/data/src/main/kotlin/net/koalastuff/koalacast/core/data/repository/ContentRefreshWorker.kt index 7b3793db..c19f406c 100644 --- a/apps/android/core/data/src/main/kotlin/net/koalastuff/koalacast/core/data/repository/ContentRefreshWorker.kt +++ b/apps/android/core/data/src/main/kotlin/net/koalastuff/koalacast/core/data/repository/ContentRefreshWorker.kt @@ -156,7 +156,7 @@ class ContentRefreshWorker @AssistedInject constructor( ) } val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) - .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setSmallIcon(R.drawable.ic_notification_new_episodes) .setContentTitle(applicationContext.getString(R.string.new_episodes_title)) .setContentText(text) .setStyle(NotificationCompat.BigTextStyle().bigText(text)) diff --git a/apps/android/core/data/src/main/res/drawable/ic_notification_new_episodes.xml b/apps/android/core/data/src/main/res/drawable/ic_notification_new_episodes.xml new file mode 100644 index 00000000..516a72d8 --- /dev/null +++ b/apps/android/core/data/src/main/res/drawable/ic_notification_new_episodes.xml @@ -0,0 +1,34 @@ + + + + + + + diff --git a/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/AmplitudeTap.kt b/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/AmplitudeTap.kt index e03fdb91..13571f00 100644 --- a/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/AmplitudeTap.kt +++ b/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/AmplitudeTap.kt @@ -322,6 +322,7 @@ internal class AmplitudeBufferSink( private var fftFill = 0 private val bandScratch = FloatArray(SPECTRUM_BANDS) private var bandEdges = spectrumBandEdges(DEFAULT_SAMPLE_RATE) + private val autoGain = AutoGain() override fun flush(sampleRateHz: Int, channelCount: Int, encoding: Int) { // Any other encoding is left alone rather than misread as shorts and drawn @@ -331,6 +332,7 @@ internal class AmplitudeBufferSink( windowBytes = envelopeWindowBytes(sampleRateHz, channelCount) strideBytes = channelCount.coerceAtLeast(1) * 2 bandEdges = spectrumBandEdges(sampleRateHz) + autoGain.reset() fftFill = 0 bytesInWindow = 0 sumInWindow = 0.0 @@ -401,6 +403,10 @@ internal class AmplitudeBufferSink( } fftInPlace(fftReal, fftImaginary) reduceToBands(fftReal, fftImaginary, bandEdges, bandScratch) + // Normalise against what this display has been hearing rather than + // against full scale, so a quietly mastered episode fills the same + // height as a loud one. Silence is left alone. + autoGain.apply(bandScratch) tap.publishBands(bandScratch) } diff --git a/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/Spectrum.kt b/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/Spectrum.kt index 7807454d..cc34fb92 100644 --- a/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/Spectrum.kt +++ b/apps/android/core/player/src/main/kotlin/net/koalastuff/koalacast/core/player/Spectrum.kt @@ -182,3 +182,55 @@ internal fun reduceToBands( out[band] = (normalised * tilt).coerceIn(0f, 1f) } } + +/** + * Where a loud passage should land, and how far the gain may reach for it. + * + * Normalising against full scale is why a quietly mastered episode drew a flat + * display while a loud one drew a lively one. A music player's spectrum looks + * alive at every volume because it is normalised against what it has been + * hearing. Podcast audio makes that more pronounced still: levelling between + * shows is far less consistent than in mastered music. + * + * Deliberately identical to the web client's `spectrum.ts`, so the two displays + * answer alike. + */ +private const val AGC_TARGET = 0.82f +private const val AGC_MAX_GAIN = 3f + +/** Below this the frame is silence or room tone, and lifting it only draws noise. */ +internal const val AGC_SILENCE = 0.06f + +/** Rises quickly so a transient pulls the gain down at once; falls slowly. */ +private const val AGC_ATTACK = 0.35f +private const val AGC_RELEASE = 0.015f + +/** The loudest band this display has been seeing, smoothed. */ +internal class AutoGain { + var reference: Float = 0f + private set + + fun reset() { + reference = 0f + } + + /** + * Scales [bands] in place so the display uses its height at any input level, + * and returns the gain applied. + * + * Silence is left alone on purpose: an empty display during a pause is + * correct, and amplifying room tone into a full-height wall is not. + */ + fun apply(bands: FloatArray): Float { + var frontRunner = 0f + for (value in bands) if (value > frontRunner) frontRunner = value + val coefficient = if (frontRunner > reference) AGC_ATTACK else AGC_RELEASE + reference += (frontRunner - reference) * coefficient + + if (reference < AGC_SILENCE) return 1f + val gain = (AGC_TARGET / reference).coerceIn(1f, AGC_MAX_GAIN) + if (gain == 1f) return 1f + for (index in bands.indices) bands[index] = (bands[index] * gain).coerceAtMost(1f) + return gain + } +} diff --git a/apps/android/core/player/src/test/kotlin/net/koalastuff/koalacast/core/player/SpectrumTest.kt b/apps/android/core/player/src/test/kotlin/net/koalastuff/koalacast/core/player/SpectrumTest.kt index 37d8a227..33f7e103 100644 --- a/apps/android/core/player/src/test/kotlin/net/koalastuff/koalacast/core/player/SpectrumTest.kt +++ b/apps/android/core/player/src/test/kotlin/net/koalastuff/koalacast/core/player/SpectrumTest.kt @@ -143,3 +143,68 @@ class SpectrumTest { assertTrue("must not reach the top in one frame, got ${out[0]}", out[0] < 1f) } } + +/** + * Normalisation against what the display has been hearing, rather than against + * full scale. Mirrors the web client's spectrum tests so the two displays keep + * answering alike. + */ +class AutoGainTest { + + private fun settle(gain: AutoGain, level: Float, frames: Int): Float { + var applied = 1f + repeat(frames) { + val bands = FloatArray(SPECTRUM_BANDS) { level } + applied = gain.apply(bands) + } + return applied + } + + @Test + fun `lifts a quietly mastered episode towards the target`() { + val gain = AutoGain() + val applied = settle(gain, level = 0.25f, frames = 400) + assertTrue("expected a lift, got $applied", applied > 1f) + + val bands = FloatArray(SPECTRUM_BANDS) { 0.25f } + gain.apply(bands) + assertTrue("the display should sit higher than the raw level", bands.max() > 0.25f) + } + + @Test + fun `leaves silence alone rather than amplifying room tone`() { + val gain = AutoGain() + val applied = settle(gain, level = AGC_SILENCE / 3f, frames = 400) + assertEquals(1f, applied, 1e-6f) + } + + @Test + fun `never pushes a band past full height`() { + val gain = AutoGain() + repeat(500) { + val bands = FloatArray(SPECTRUM_BANDS) { 0.3f } + gain.apply(bands) + for (value in bands) assertTrue("band exceeded full height: $value", value <= 1f) + } + } + + @Test + fun `backs off quickly when a loud passage arrives`() { + val gain = AutoGain() + settle(gain, level = 0.2f, frames = 400) + val lifted = gain.apply(FloatArray(SPECTRUM_BANDS) { 0.2f }) + + var applied = 1f + repeat(20) { applied = gain.apply(FloatArray(SPECTRUM_BANDS) { 0.95f }) } + assertTrue("gain should fall back, was $lifted then $applied", applied < lifted) + assertEquals(1f, applied, 0.15f) + } + + @Test + fun `a format change forgets the previous reference`() { + val gain = AutoGain() + settle(gain, level = 0.2f, frames = 400) + gain.reset() + assertEquals(0f, gain.reference, 1e-6f) + } +} diff --git a/apps/web/package.json b/apps/web/package.json index 9ba65284..baf3cb89 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "koalacast-web", - "version": "0.11.5", + "version": "0.11.6", "private": true, "packageManager": "npm@11.16.0", "type": "module", diff --git a/apps/web/src/lib/audio/engine.ts b/apps/web/src/lib/audio/engine.ts index 62d7125d..6da5c105 100644 --- a/apps/web/src/lib/audio/engine.ts +++ b/apps/web/src/lib/audio/engine.ts @@ -1,3 +1,13 @@ +import { + applyAutoGain, + createAutoGainState, + reduceToBands, + spectrumBandEdges, + SPECTRUM_CEILING_DB, + SPECTRUM_FLOOR_DB, + type AutoGainState +} from '$lib/audio/spectrum'; + // Web Audio API Engine for KoalaCast // Handles Volume Boost (Gain + Compressor) and Real-time Silence Detection (Analyser) @@ -10,6 +20,9 @@ export class AudioEngine { private outputGainNode: GainNode | null = null; private levelData: Uint8Array | null = null; private freqData: Uint8Array | null = null; + /** Cached per-band bin ranges; the sample rate cannot change under a graph. */ + private bandEdges: Int32Array | null = null; + private autoGain: AutoGainState = createAutoGainState(); public volumeBoost = false; public skipSilence = false; @@ -38,12 +51,20 @@ export class AudioEngine { this.gainNode = this.audioCtx.createGain(); this.compressorNode = this.audioCtx.createDynamicsCompressor(); this.analyserNode = this.audioCtx.createAnalyser(); - // 1024 gives ~43 Hz bins at 44.1 kHz, which is the coarsest resolution - // that still separates a voice's fundamental from the band below it. + // 2048 gives ~21 Hz bins at 44.1 kHz. 1024 was too coarse for the bottom + // of a log-spaced display: at 43 Hz per bin the lowest dozen bands all + // landed on the same one or two bins and drew the same number, which is + // most of what "only the left edge moves" was. + this.analyserNode.fftSize = 2048; // The analyser's own smoothing is lowered from the 0.8 default because // the visualiser is redrawn every frame and 0.8 visibly lags the audio. - this.analyserNode.fftSize = 1024; this.analyserNode.smoothingTimeConstant = 0.6; + // Without this the defaults apply: -100 to -30. Ordinary mastered speech + // spends most of a sentence above a -30 ceiling, so band after band sat + // pinned at 255 — and a clipped bar cannot move. The window matches the + // Android client's exactly, so both displays answer alike. + this.analyserNode.minDecibels = SPECTRUM_FLOOR_DB; + this.analyserNode.maxDecibels = SPECTRUM_CEILING_DB; this.levelData = new Uint8Array(this.analyserNode.fftSize); this.freqData = new Uint8Array(this.analyserNode.frequencyBinCount); @@ -90,6 +111,8 @@ export class AudioEngine { this.outputGainNode = null; this.levelData = null; this.freqData = null; + this.bandEdges = null; + this.autoGain = createAutoGainState(); this.volumeBoost = false; this.skipSilence = false; } @@ -127,11 +150,8 @@ export class AudioEngine { * Fills [out] with one 0..1 energy per band, low frequencies first, for a * spectrum display. Returns false when there is no graph to read. * - * Bands are log-spaced, because linear bins put nine tenths of a spectrum - * display above 4 kHz where speech has almost nothing, and the result is a - * row of bars in which only the leftmost two ever move. They are also tilted - * upwards with frequency to offset the natural rolloff of recorded speech, - * so the right-hand bars are visible rather than technically-correct stubs. + * The mapping itself lives in `spectrum.ts` so it can be tested without a + * browser; this method is only the part that needs a live AnalyserNode. */ public getSpectrum(out: Float32Array): boolean { const analyser = this.analyserNode; @@ -139,39 +159,16 @@ export class AudioEngine { if (!analyser || !data || !this.audioCtx) return false; analyser.getByteFrequencyData(data); - const nyquist = this.audioCtx.sampleRate / 2; - const bins = data.length; - const bands = out.length; - const logMin = Math.log(SPECTRUM_MIN_HZ); - const logMax = Math.log(SPECTRUM_MAX_HZ); - - for (let band = 0; band < bands; band++) { - const lowHz = Math.exp(logMin + ((logMax - logMin) * band) / bands); - const highHz = Math.exp(logMin + ((logMax - logMin) * (band + 1)) / bands); - let lowBin = Math.floor((lowHz / nyquist) * bins); - let highBin = Math.ceil((highHz / nyquist) * bins); - lowBin = Math.max(0, Math.min(bins - 1, lowBin)); - // Narrow bands at the bottom can collapse onto a single bin; never let - // a band read zero bins and render as a permanent gap. - highBin = Math.max(lowBin + 1, Math.min(bins, highBin)); - - // Peak, not mean: averaging across a band that spans several kHz buries - // every transient, and transients are the part a listener recognises. - let peak = 0; - for (let bin = lowBin; bin < highBin; bin++) { - if (data[bin] > peak) peak = data[bin]; - } - const tilt = 1 + (SPECTRUM_TILT * band) / Math.max(1, bands - 1); - out[band] = Math.max(0, Math.min(1, (peak / 255) * tilt)); + if (!this.bandEdges || this.bandEdges.length !== out.length + 1) { + this.bandEdges = spectrumBandEdges(this.audioCtx.sampleRate, data.length, out.length); } + reduceToBands(data, this.bandEdges, out); + // Normalise against what this display has been hearing rather than against + // full scale, so a quietly mastered episode fills the same height as a loud + // one. Silence is left alone. + applyAutoGain(out, this.autoGain); return true; } } -/** Below this is rumble, above it is hiss; neither says anything about speech. */ -const SPECTRUM_MIN_HZ = 55; -const SPECTRUM_MAX_HZ = 12_000; -/** The top band ends up with this much extra gain over the bottom one. */ -const SPECTRUM_TILT = 1.6; - export const audioEngine = new AudioEngine(); diff --git a/apps/web/src/lib/audio/spectrum.test.ts b/apps/web/src/lib/audio/spectrum.test.ts new file mode 100644 index 00000000..e3afd44e --- /dev/null +++ b/apps/web/src/lib/audio/spectrum.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { + AGC_MAX_GAIN, + AGC_SILENCE, + applyAutoGain, + createAutoGainState, + reduceToBands, + spectrumBandEdges, + SPECTRUM_MAX_HZ, + SPECTRUM_MIN_HZ +} from './spectrum'; + +const SAMPLE_RATE = 44_100; +const BINS = 1024; // fftSize 2048 +const BANDS = 48; + +function edges() { + return spectrumBandEdges(SAMPLE_RATE, BINS, BANDS); +} + +/** + * A byte spectrum shaped like speech: strong around the fundamental, rolling + * off with frequency, and essentially nothing above 10 kHz. This is the input + * that used to leave most of the display motionless. + */ +function speechLikeSpectrum(scale = 1): Uint8Array { + const data = new Uint8Array(BINS); + const nyquist = SAMPLE_RATE / 2; + for (let bin = 0; bin < BINS; bin++) { + const hz = (bin / BINS) * nyquist; + // Real recordings carry room tone below the fundamental. Starting the + // fixture above the display's lowest band would test a silence the input + // created, not one the mapping did. + if (hz < 40) continue; + // -9 dB per octave above 200 Hz, which is roughly what recorded speech does. + const octaves = Math.max(0, Math.log2(Math.max(hz, 200) / 200)); + const magnitude = Math.max(0, 210 - octaves * 32); + data[bin] = Math.round(Math.min(255, magnitude * scale)); + } + return data; +} + +describe('spectrumBandEdges', () => { + it('gives every band at least one bin, so no bar is a permanent gap', () => { + const bandEdges = edges(); + expect(bandEdges).toHaveLength(BANDS + 1); + for (let band = 0; band < BANDS; band++) { + expect(bandEdges[band + 1]).toBeGreaterThan(bandEdges[band]); + } + }); + + it('spans the intended range and stays inside the bin count', () => { + const bandEdges = edges(); + const nyquist = SAMPLE_RATE / 2; + const firstHz = (bandEdges[0] / BINS) * nyquist; + const lastHz = (bandEdges[BANDS] / BINS) * nyquist; + expect(firstHz).toBeLessThanOrEqual(SPECTRUM_MIN_HZ * 1.5); + expect(lastHz).toBeLessThanOrEqual(SPECTRUM_MAX_HZ * 1.05); + expect(bandEdges[BANDS]).toBeLessThanOrEqual(BINS); + }); + + it('refuses a configuration with fewer bins than bands', () => { + expect(() => spectrumBandEdges(SAMPLE_RATE, 32, 48)).toThrow(); + }); + + // The bug this whole file exists for: at fftSize 1024 the lowest bands landed + // on the same one or two bins and drew the same number, so the left edge of + // the display moved as one block. + it('resolves the low bands onto distinct bins at the shipped FFT size', () => { + const bandEdges = edges(); + const lowest = new Set(); + for (let band = 0; band < 12; band++) lowest.add(bandEdges[band]); + expect(lowest.size).toBe(12); + }); +}); + +describe('reduceToBands', () => { + it('lights up the whole display for speech, not just the left edge', () => { + const out = new Float32Array(BANDS); + reduceToBands(speechLikeSpectrum(), edges(), out); + + const silent = [...out].filter((value) => value <= 0.001).length; + expect(silent).toBe(0); + // The top third has to carry visible energy, which is what the tilt is for. + const topThird = [...out].slice(Math.floor((BANDS * 2) / 3)); + expect(Math.max(...topThird)).toBeGreaterThan(0.15); + }); + + it('does not pin the display at full height', () => { + const out = new Float32Array(BANDS); + reduceToBands(speechLikeSpectrum(), edges(), out); + // A clipped bar cannot move, and a display of clipped bars is exactly the + // "everything is static" report. The tilt was 1.6 here, which lifted the + // top bands by 2.6x and clipped them on their own. + const clipped = [...out].filter((value) => value >= 0.999).length; + expect(clipped).toBeLessThan(BANDS / 3); + }); + + it('stays within range and rises with input level', () => { + const quiet = new Float32Array(BANDS); + const loud = new Float32Array(BANDS); + reduceToBands(speechLikeSpectrum(0.4), edges(), quiet); + reduceToBands(speechLikeSpectrum(1), edges(), loud); + for (let band = 0; band < BANDS; band++) { + expect(loud[band]).toBeGreaterThanOrEqual(quiet[band] - 1e-6); + expect(loud[band]).toBeLessThanOrEqual(1); + expect(quiet[band]).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('applyAutoGain', () => { + function settle(bands: () => Float32Array, frames: number) { + const state = createAutoGainState(); + let last = new Float32Array(BANDS); + let gain = 1; + for (let frame = 0; frame < frames; frame++) { + last = bands(); + gain = applyAutoGain(last, state); + } + return { bands: last, gain, state }; + } + + it('lifts a quietly mastered episode towards the target', () => { + const quiet = () => { + const out = new Float32Array(BANDS); + reduceToBands(speechLikeSpectrum(0.35), edges(), out); + return out; + }; + const before = quiet(); + const { bands: after, gain } = settle(quiet, 400); + expect(gain).toBeGreaterThan(1); + expect(Math.max(...after)).toBeGreaterThan(Math.max(...before)); + }); + + it('leaves silence alone rather than amplifying room tone', () => { + const { gain, bands } = settle(() => { + const out = new Float32Array(BANDS); + out.fill(AGC_SILENCE / 3); + return out; + }, 400); + expect(gain).toBe(1); + expect(Math.max(...bands)).toBeLessThan(AGC_SILENCE); + }); + + it('never exceeds its gain ceiling', () => { + const { gain } = settle(() => { + const out = new Float32Array(BANDS); + out.fill(AGC_SILENCE * 1.2); + return out; + }, 2000); + expect(gain).toBeLessThanOrEqual(AGC_MAX_GAIN); + }); + + it('backs off quickly when a loud passage arrives', () => { + const state = createAutoGainState(); + const quiet = new Float32Array(BANDS); + quiet.fill(0.2); + for (let frame = 0; frame < 400; frame++) applyAutoGain(Float32Array.from(quiet), state); + const liftedGain = Math.min(AGC_MAX_GAIN, 0.82 / state.reference); + + const loud = new Float32Array(BANDS); + loud.fill(0.95); + let gain = 1; + for (let frame = 0; frame < 20; frame++) gain = applyAutoGain(Float32Array.from(loud), state); + expect(gain).toBeLessThan(liftedGain); + expect(gain).toBeCloseTo(1, 1); + }); + + it('never pushes a band past full height', () => { + const state = createAutoGainState(); + for (let frame = 0; frame < 500; frame++) { + const out = new Float32Array(BANDS); + out.fill(0.3); + applyAutoGain(out, state); + for (const value of out) expect(value).toBeLessThanOrEqual(1); + } + }); +}); diff --git a/apps/web/src/lib/audio/spectrum.ts b/apps/web/src/lib/audio/spectrum.ts new file mode 100644 index 00000000..08072e9a --- /dev/null +++ b/apps/web/src/lib/audio/spectrum.ts @@ -0,0 +1,140 @@ +// The frequency half of the visualiser signal. +// +// Kept apart from the audio graph so the arithmetic can be tested without a +// browser: an AnalyserNode cannot be driven from a unit test, and every failure +// this file exists to prevent is a failure of the mapping rather than of the +// audio plumbing. +// +// The Android client reaches the same numbers through its own FFT (see +// core/player/Spectrum.kt). The constants below are deliberately identical to +// its, because the two displays are the same product and drifting tunings are +// how one of them ends up looking broken while the other does not — which is +// exactly what happened here. + +/** Below this is rumble, above it is hiss; neither says anything about speech. */ +export const SPECTRUM_MIN_HZ = 60; +export const SPECTRUM_MAX_HZ = 12_000; + +/** + * The visible window, in dBFS. + * + * The AnalyserNode's defaults are -100 and -30, and this file used to leave + * them alone. A -30 ceiling is below the level ordinary mastered speech spends + * most of a sentence at, so band after band sat pinned at 255 — and a bar that + * is clipped does not move. Half the display looking frozen was that. + */ +export const SPECTRUM_FLOOR_DB = -78; +export const SPECTRUM_CEILING_DB = -4; + +/** + * Recorded speech rolls off with frequency; without this the right half is dead. + * Modest on purpose: at 1.6 the top bands are lifted by 2.6x and clip on their + * own, which is the same frozen display arriving from the other end. + */ +export const SPECTRUM_TILT = 0.8; + +/** + * The FFT bin index each band starts at, plus a final entry for the end of the + * last band, so a band's bins are `edges[i]` until `edges[i + 1]`. + * + * Log-spaced: linear spacing puts nine tenths of the display above 4 kHz where + * speech has almost nothing, leaving a row of bars in which only the leftmost + * two ever move. Every band is guaranteed at least one bin, so no bar renders + * as a permanent gap. + */ +export function spectrumBandEdges( + sampleRateHz: number, + binCount: number, + bands: number +): Int32Array { + if (bands >= binCount) throw new Error('spectrum needs more FFT bins than bands'); + const nyquist = Math.max(1, sampleRateHz) / 2; + const logMin = Math.log(SPECTRUM_MIN_HZ); + const logMax = Math.log( + Math.max(SPECTRUM_MIN_HZ * 2, Math.min(SPECTRUM_MAX_HZ, nyquist)) + ); + const edges = new Int32Array(bands + 1); + for (let band = 0; band <= bands; band++) { + const hz = Math.exp(logMin + ((logMax - logMin) * band) / bands); + edges[band] = Math.min(binCount, Math.max(0, Math.floor((hz / nyquist) * binCount))); + } + // The bottom bands are narrower than one bin at any practical FFT size, so + // without this they collapse onto each other and a stretch of bars all draw + // the same number. Safe without an upper clamp only because there are far + // more bins than bands, which the guard above enforces. + for (let band = 1; band <= bands; band++) { + if (edges[band] <= edges[band - 1]) edges[band] = edges[band - 1] + 1; + } + return edges; +} + +/** + * Reduces one byte-magnitude spectrum to per-band energies, 0..1. + * + * `data` is what `AnalyserNode.getByteFrequencyData` produces, which is already + * the dB window mapped onto 0..255 — provided the window was configured; see + * SPECTRUM_FLOOR_DB. Peak within a band rather than mean, because averaging + * across a band spanning several kHz buries every transient, and transients are + * the part a listener recognises. + */ +export function reduceToBands(data: Uint8Array, edges: Int32Array, out: Float32Array): void { + for (let band = 0; band < out.length; band++) { + let peak = 0; + const end = Math.min(data.length, edges[band + 1]); + for (let bin = edges[band]; bin < end; bin++) { + if (data[bin] > peak) peak = data[bin]; + } + const tilt = 1 + (SPECTRUM_TILT * band) / Math.max(1, out.length - 1); + out[band] = Math.min(1, (peak / 255) * tilt); + } +} + +/** + * Where a loud passage should land, and how far the gain may reach for it. + * + * This is the part neither client had, and the reason a quiet episode drew a + * flat display while a loud one drew a lively one. A music player's spectrum + * looks alive at every volume because it is normalised against what it has been + * hearing, not against full scale. Podcast audio makes it more pronounced still: + * levelling between shows is far less consistent than in mastered music. + */ +export const AGC_TARGET = 0.82; +export const AGC_MAX_GAIN = 3; +/** Below this the frame is silence or room tone, and lifting it only draws noise. */ +export const AGC_SILENCE = 0.06; +/** Rises quickly so a transient pulls the gain down at once; falls slowly. */ +export const AGC_ATTACK = 0.35; +export const AGC_RELEASE = 0.015; + +export interface AutoGainState { + /** The loudest band this display has been seeing, smoothed. */ + reference: number; +} + +export function createAutoGainState(): AutoGainState { + return { reference: 0 }; +} + +/** + * Scales `bands` in place so the display uses its height at any input level. + * + * Returns the gain applied, for tests and for callers that want to reason about + * it. Silence is deliberately left alone: an empty display during a pause is + * correct, and amplifying room tone into a full-height wall is not. + */ +export function applyAutoGain(bands: Float32Array, state: AutoGainState): number { + let frontRunner = 0; + for (let band = 0; band < bands.length; band++) { + if (bands[band] > frontRunner) frontRunner = bands[band]; + } + const coefficient = frontRunner > state.reference ? AGC_ATTACK : AGC_RELEASE; + state.reference += (frontRunner - state.reference) * coefficient; + + if (state.reference < AGC_SILENCE) return 1; + const gain = Math.min(AGC_MAX_GAIN, Math.max(1, AGC_TARGET / state.reference)); + if (gain === 1) return 1; + for (let band = 0; band < bands.length; band++) { + bands[band] = Math.min(1, bands[band] * gain); + } + return gain; +}