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; +}