Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ android {

defaultConfig {
applicationId = "net.koalastuff.koalacast"
versionCode = 44
versionName = "0.11.4"
versionCode = 45
versionName = "0.11.5"
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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 is one.

The feed mark rather than a bell: it is what "new items in a show you follow"
means, and it survives being drawn at 24dp in a status bar, which a bell's
clapper and shoulders do not. Constructed from exact geometry rather than
traced by hand — a dot plus two annular quarter-sectors sharing the origin at
(48, 208):

dot centre (76, 180) r 26
inner arc radii 72 .. 100
outer arc radii 140 .. 168

Each sector runs from its topmost point clockwise to its rightmost point, so
the outer arc sweeps 1 and the returning inner edge sweeps 0.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:fillColor="@android:color/white"
android:pathData="M76,154a26,26 0 1,0 0,52a26,26 0 1,0 0,-52Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M48,108A100,100 0 0,1 148,208L120,208A72,72 0 0,0 48,136Z" />
<path
android:fillColor="@android:color/white"
android:pathData="M48,40A168,168 0 0,1 216,208L188,208A140,140 0 0,0 48,68Z" />
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "koalacast-web",
"version": "0.11.5",
"version": "0.11.6",
"private": true,
"packageManager": "npm@11.16.0",
"type": "module",
Expand Down
73 changes: 35 additions & 38 deletions apps/web/src/lib/audio/engine.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -10,6 +20,9 @@ export class AudioEngine {
private outputGainNode: GainNode | null = null;
private levelData: Uint8Array<ArrayBuffer> | null = null;
private freqData: Uint8Array<ArrayBuffer> | 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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -127,51 +150,25 @@ 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;
const data = this.freqData;
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();
Loading