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