Equations & Math Reference
Every formula behind the LOKLOK radio brain, in one place. Each section names the file + constant that implements the equation so you can jump from the math to the code.
For the why behind the equations — the psychoacoustics, loudness standards, and scheduling theory — see Broadcast Audio Science and Rotation Science & Comparison. In-text citations follow APA 7th edition; the full list is on References.
0. How to read this page
This page is organized as a pipeline: loudness (§1) → genre selection (§2–6) → track selection (§7) → classification (§8) → learning (§9) → constraints (§10–11) → playout (§12–15). Each section gives the closed form, the code that implements it, and — where it matters — the derivation intuition so you can extend the formula without rediscovering the rationale.
A running theme is that the system separates static targets (the loudness box, the genre split, the daypart multipliers) from dynamic corrections (anti-fatigue, crowd bias, RL arms). The static targets encode the station's identity; the dynamic corrections keep the realized output on target when the random draws wander. This separation — constants at the top of the file, feedback loops in the hot path — is what makes the scheduler both tunable and stable.
1. Loudness: EBU R128 (two-pass)
Files: content-pipeline.js (normalizeR128, R128), smart-scheduler.js
(normalizeLoudness, R128), content-manager.js (identity stingers).
The station masters every asset to the streaming R128 target:
Pass 1 (measure-only): runs loudnorm=...:print_format=json against a null
output and parses the measured input_i, input_tp, input_lra,
input_thresh, target_offset.
Pass 2 (render): replays loudnorm with the measured values fed back in and
linear=true, so the gain is a constant offset rather than a dynamic compress:
the filter measures the integrated loudness of the whole file, then applies
where is the K-weighted signal (high-shelf boost around 1.7 kHz plus a high-pass at 40 Hz, per BS.1770; International Telecommunication Union, 2015), gated over 400 ms blocks with absolute and relative gates. The true-peak check runs on a 192 kHz oversampled signal so inter-sample clipping is caught:
Identity stingers use a tighter spoken-voice target I=-23:TP=-1.0:LRA=7
(content-manager.js).
Why two passes? A single-pass dynamic loudnorm can over-compress by tracking the level in real time — it has no idea how loud the rest of the file is when it processes the first second. Two-pass measurement removes that uncertainty: pass 1 sees the entire file and returns the gated integrated value, true peak, and LRA; pass 2 replays the file with a constant gain chosen so the measured value lands exactly on target. Constant gain = no dynamics reshaped = the track keeps its original dynamics, which matters for a broadcast where every asset is expected to sit at the same level without sounding "processed" (Vickers, 2012; European Broadcasting Union, 2020a).
Why the −0.691 offset in the loudness formula? The K-weighting filters are
not normalized to unity gain; the constant −0.691 is the DC gain of the
weighting network, subtracted so that a full-scale square wave measures as ~0
LUFS rather than slightly above. It is a calibration constant from the BS.1770
specification, not a tunable knob (International Telecommunication Union, 2015).
2. Genre selection: Dirichlet sampling
File: smart-scheduler.js — _dirichletPick, _pickMusicCategory.
Base concentrations come from SCHEDULER_CONFIG.GENRE_SPLIT. After all bias
multipliers below, each category weight is scaled into a concentration
. A Dirichlet draw picks the category:
Implemented with the Marsaglia & Tsang gamma sampler, which in turn uses a Box–Muller normal pair:
Sampling instead of argmax gives the "smooth-but-varied" behavior: over many
draws the mean is (the configured
split), but individual rotations wobble around it instead of locking in. This is
the standard Dirichlet-multinomial construction (Blei, Ng, & Jordan, 2003); the
Gamma sampler is due to Marsaglia & Tsang (2000), built on the Box–Muller
transform (Box & Muller, 1958).
Why scale the weights by 6? The concentration parameter controls the spread of the draws. A larger total concentration makes the proportions cling tightly to the mean (less wobble); a smaller one lets them swing widely. The factor 6 is a hand-tuned compromise: large enough that the brand mix stays recognizable hour to hour, small enough that consecutive rotations do not feel like carbon copies. Raise it for a more predictable station; lower it for a wilder one.
Why Gamma, and why the Marsaglia–Tsang sampler? A Dirichlet draw is most efficiently produced by the concentration-parameter identity: draw one Gamma per category with shape , then normalize. The Marsaglia–Tsang (2000) sampler produces a Gamma in expected constant time by rejection sampling over a log-transformed distribution, and it needs a standard normal pair — which is exactly what the Box–Muller transform provides. The pipeline is therefore three calls deep (Box–Muller → Gamma → normalize), but each is trivial and deterministic-per-seed, so the whole thing is fast and reproducible.
3. Daypart multipliers
File: smart-scheduler.js — _pickMusicCategory (time-of-day profile +
SCHEDULER_CONFIG.DAYPARTS).
Every category weight is multiplied by a profile based on the current hour:
| Window | arabic_hits | egyptian_trends | arab_techno | english |
|---|---|---|---|---|
| 02–05 (deep night) | ×0.3 | ×0.5 | ×2.0 | ×8.0 |
| 05–11 (morning) | ×2.2 | ×1.1 | ×0.2 | ×2.0 |
| 11–16 (midday) | ×1.3 | ×1.5 | ×0.7 | ×1.1 |
| 16–23 (evening) | ×0.9 | ×1.5 | ×1.6 | ×0.9 |
| 23–02 (late night) | ×0.3 | ×0.6 | ×3.5 | ×1.2 |
An agent-configured daypart can hard-gate a category (hard: 'english') or apply
its own per-category weights (multiplicative over the base). The 02–05 window is
a hard gate to English unless that deck is empty.
Why are the multipliers asymmetric? The table encodes an audience model: deep-night listeners are the most energetic and English-leaning (the 02–05 English ×8.0 gate is a hard product decision, not a probability); morning drive is mainstream and locally-leaning (arabic_hits ×2.2); evening skews toward danceable techno and trends; late night returns to techno (×3.5). Each multiplier is a prior about who is listening right now, and because it is multiplicative over the base weight, an operator can change the station's whole day personality by editing one table — the rest of the pipeline needs no change.
Daypart boundary trigger (2026-08-15)
The on-air rotation is now daypart-stamped: every build records which profile
it was generated under, and a rebuild fires the moment the profile changes — no
waiting for the 85% consumption threshold. The canonical key k(t) mirrors
_pickMusicCategory exactly:
and, when SCHEDULER_CONFIG.DAYPARTS is set,
A rotation refresh is required iff the stamped key differs from the live key:
A 60-second watchdog in server.js probes the string compare and calls
rotateBatch() within a minute of any boundary crossing, so a rotation built
before 02:00 can never keep airing Arabic through the English-only deep-night
window.
4. Anti-fatigue correction
File: smart-scheduler.js — _pickMusicCategory step 4.
Over the last played categories, the observed frequency is compared to the expected weight , and the weight is nudged toward the gap:
This is a proportional controller on "we played this category too much vs. its target": an under-played genre gets boosted, an over-played one damped. The structure is textbook negative feedback — error signal , gain 0.5, output clamp at 0.02 (Åström & Murray, 2021).
Why does this exist at all? A pure Dirichlet sampler would eventually self-correct statistically, but "eventually" can mean hours of a stale mix while a listener is tuning in right now. Feedback control reacts within the next draw. The gain 0.5 is deliberately below 1 — a full correction would overcorrect into category oscillation (play rap to fix the deficit, then dump everything else to fix the surplus). The 0.02 floor guarantees a heavily over-played category is never eliminated, so the rotation cannot hard-stall for lack of an eligible category.
5. Crowd energy bias
File: smart-scheduler.js — _pickMusicCategory step 3.
When listener count CROWD_SIZE_THRESHOLD (5):
Techno gets the full hype boost, hits get half, so a growing audience shifts the mix toward high energy without flipping it entirely.
Rationale: a large concurrent audience is the station's strongest live signal that "it's a party right now." The bias is a gentle throttle: it weights toward high-energy music but does not gate or force — the Dirichlet sampler still wanders, just with a different center of gravity. The halving-of-hits coefficient (0.70 vs 0.35) keeps the top-40 familiar while the energy push comes from the techno lane.
6. VAE "professional DNA" nudge
File: smart-scheduler.js — _pickMusicCategory step 2, StationVibeVAE.
The VAE (LSTM 8→6→3→6→8 auto-encoder) is trained on motifs of professional corporate streams. Given the last category it suggests a soft distribution over the 8 categories; music weights are nudged:
with (0.7× to 1.3×).
What the VAE is actually doing: trained on motifs (category n-grams) of "professional corporate streams", the auto-encoder learns a low-dimensional latent code that captures what those streams' transitions look like. Given the recent history it suggests a soft next-category distribution; the nudge keeps that suggestion bounded (±30%) so the learned flavor decorates the Dirichlet choice without overriding it. This is the closest the scheduler comes to "emulating a polished radio show" — the anti-fatigue controller keeps it honest by preventing any single suggestion from dominating.
7. Track scoring (NN + VAE blend)
File: smart-scheduler.js — drawCard, TrackSuccessPredictor.
Each candidate in the pool (up to 6, all off-cooldown) gets a score; the best is selected:
where is the LSTM (6→16→1) success prediction for the track and the VAE's affinity for that track type. Inputs to the LSTM are normalized features: category map , energy (1.0 for elements, 0.5 music), time of day , listener count , freshness flag.
Why a 70/30 blend? The NN predicts success (listener growth per track) but is trained on sparse signals and can overfit to a few noisy plays; the VAE encodes structural fit (does this track type belong in this sequence?) and is stable by construction. Blending gives the selector a robust compromise: when the NN is confident, its 0.7 share dominates; when it is uncertain, the VAE keeps the pick on-brand rather than letting a random overfit spike through. The candidate pool (up to 6, all off-cooldown) bounds the search — the scheduler never grades the whole library, only a small freshly-eligible window, which keeps the build fast and the picks fresh.
8. Audio classifier (6→12→8→4 Perceptron)
File: smart-scheduler.js — AudioFeatureClassifier, AdvancedTrainer.
Classifies pro-stream segments as sfx / jingles / arabic_hits / ads from 6 audio features (RMS, dynamic range, zero-crossing rate, BPM, spectral flatness, voice probability). Trained with L2-regularized gradient descent:
using LeakyReLU activation if else .
Design notes: LeakyReLU's 0.01 slope (rather than ReLU's hard 0) keeps gradients alive for dead neurons, which helps the small 4-node output layer converge on sparse audio-feature data. The L2 term is weight decay — it shrinks every weight toward zero each step, which penalizes overfitting and keeps the learned boundary smooth. The 6 features were chosen because they are cheap to compute with ffmpeg and jointly discriminate the content roles (jingles are speechy + steady, sfx are percussive + wide-band, ads are loud + voice-heavy).
9. Reinforcement learning arms (experimental engine)
File: smart-scheduler-HOLISTC.js — ReinforcementLearningEngine.
Three arms (CHILL / BALANCED / PARTY) hold Beta posteriors. Success/failure updates come from listener deltas and votes:
Each decision samples each arm's posterior and plays the argmax (Thompson sampling):
The sampler uses the ratio-of-gammas identity: with independent gamma draws, . Thompson sampling is the canonical explore/exploit strategy that chooses an arm with probability equal to the posterior probability it is optimal (Thompson, 1933; Russo et al., 2018).
Why Thompson sampling instead of epsilon-greedy? Epsilon-greedy spends a
fixed exploration budget uniformly (wasted on clearly-bad arms) and exploits the
rest greedily (vulnerable to an early unlucky streak). Thompson sampling draws
from the posterior — an arm that looks bad but is unproven is still tried
often; an arm that is proven bad is tried almost never, and the update rule
(Beta counts) is a one-line closed form. It is the canonical explore/exploit
trade-off for the mood arms, and the Beta prior conveniently doubles as a
readable "win/loss record" for operators inspecting rl_arms.json.
10. Cooldowns (repeat suppression)
Files: smart-scheduler.js — isOnCooldown; smart-scheduler-parameters.js.
A track is ineligible if its last play is inside its window:
Deck refill during rotation build uses a longer freshness window,
MUSIC_FRESH_MS = 3 h, so a song stays out of a rebuilt rotation longer than the
queue-level cooldown.
Why two different windows? The queue-level cooldown (90 min) is the floor —
it only prevents a track from being selected too soon. But rotation rebuilds
rewrite the whole M3U, and a track at the tail of the old buffer could reappear
at the head of the new one within minutes of its last airing unless the rebuild
applies its own, stricter gate. MUSIC_FRESH_MS = 3 h is that gate: a rebuild
never resurrects a track aired within the last 3 hours, which is what makes
rebuilds safe (no "same song again right after the reload" — the original
complaint that killed naive auto-rebuild).
Why ~50–70 distinct songs per 6 h: a 6-hour window fits ≈100 song slots at ~3.5 min each, but the 90-min cooldown means each song can only return after 90 min. With a ~120-track buffer refreshed at 85%, the steady-state distinct count per 6-h block lands in the 50–70 range.
11. Rotation regen trigger
File: smart-scheduler-parameters.js — SCHEDULER_CONFIG.ROTATION.
The on-air rotation is rewritten once it is consumed enough:
Since 2026-08-15 this is an OR with the daypart boundary check (Eq. 3.1):
a rotation built under a different daypart profile is regenerated immediately,
regardless of consumption. Regen excludes tracks played within
MUSIC_FRESH_MS (3 h), so rewrites keep the buffer fresh without re-feeding the
just-played track to the head of the M3U.
Why rebuild at 85% and not 100%? Two reasons. (1) The rebuild is not instant — the push, the remote verification, and the Liquidsoap reload all take time, and the rotation must never empty in between. (2) A rebuild restarts playback from the new head; if it fired at 100% consumed, a slow rebuild could starve the stream into dead air. The 15% margin (≈18 tracks ≈ 1 hour) is the safety buffer that guarantees the next rebuild always has a full rotation ready before the current one exhausts.
12. Crossfade & ducking (Liquidsoap)
Files: /home/sms/radio/radio_worker.liq — cross(2.0s, ...),
amplify(0.05, ...).
Songs transition with a 2-second crossfade ( s). The linear fade pair during the overlap is
A linear-amplitude taper is deliberately simple and predictable; because every asset is loudness-normalized to the same target, the two overlapping tails are close in level and the small center-dip of a linear (non-equal-power) crossfade stays perceptually minor (Holman, 2010; see Broadcast Audio Science).
Imaging (jingles/sweepers/sfx/ads) hard-cuts instead of crossfading. When the live/voice ducking path engages, music is attenuated to
under the voice (amplify(0.05)), then restored after the voice ends.
Per-track crossfade override (liq_cross_duration, 2026-08-15)
Liquidsoap's cross pre-buffers duration (2 s) of the incoming track before
every transition. For tracks shorter than 2 s that is buffer starvation — the log
filled with the warning End of track reached while buffering next track data, crossfade duration is longer than the track's duration
(72 warnings, all on -> sfx, whose
stingers are 0.24–0.31 s). The maintainer-recommended fix (savonet/liquidsoap
#4781) is a per-track metadata override:
The scheduler emits annotate:type=<type>,liq_cross_duration=<d>:<path> in the
M3U for every short/imaging entry (CROSS_OVERRIDE in _writeM3uFile); cross
reads the liq_cross_duration tag via its default override_duration and only
pre-buffers that many seconds around short tracks. Music keeps the full 2 s
overlap. Each override value is strictly less than the shortest track in its
category (stingers 0.24 s → 0.2 s).
Why a metadata override and not a shorter global crossfade? A global
cross(duration=0.2, ...) would shorten the music crossfade too, which
degrades the flagship 2-second overlap between songs. The liq_cross_duration
override is per-track: it lets the mixer know "this specific item is too short
to pre-buffer 2 seconds" and shorten only that item's pre-buffer. The result
is that a 0.24 s stinger gets its 0.2 s pre-buffer (a hard-cut on the music,
exactly what a stinger is for), while two full songs still overlap for 2 s.
This is the maintainer-recommended pattern (savonet/liquidsoap #4781), and it
eliminated the 72 × crossfade duration is longer than the track's duration
warnings that had caused audible stutter on short non-song items.
13. Pro-stream segmenting
File: smart-scheduler.js — _analyzeProStream.
Silence detection silencedetect=noise=-28dB:d=0.3 splits the corporate stream
into clips; clips longer than 2 s are classified (Eq. 8), archived, and their
motif fed to the VAE (Eq. 6). Onset detection uses standard variance/standard
deviation across the frame:
14. Mashup tempo lock
File: content-pipeline.js — mashup builder.
Sources must lock to the target BPM within ±4%, or the segment is rejected:
This guarantees a mashup never sounds faster or slower than the original songs.
Why ±4% and not ±2% or ±8%? The threshold is the smallest margin that still yields a usable mashup pool. Below ~4% the pool shrinks because few tracks share a BPM that closely; above ~4% the perceptual pitch change becomes audible on vocals (a human ear detects ~5–6% pitch shift on a lead voice, so 4% stays under the perceptual cliff while still allowing most tracks to pair). The check is applied before the tempo stretch, so a rejected segment never wastes render time, and the stretch itself is resampling, not time-stretch — it changes pitch with speed, which is exactly the artifact the ±4% lock is protecting against.
15. Signature-jingle stochastic cadence (2026-08-15)
File: smart-scheduler.js — updateM3U (SIG_JINGLE_NAME,
SIG_JINGLE_MIN_SLOTS = 2, SIG_JINGLE_MAX_SLOTS = 5).
The station signature (loklok1_mixdown.mp3) is excluded from the random
jingles pool — it only ever airs through reserved imaging slots. After each
firing the next target slot is re-rolled uniformly:
so the expected gap is slots (~every 30–45 min)
with a randomized rhythm — no metronomic "every 3rd slot" feel. The sig fires when
imagingSlotsFilled >= s_k, then s_{k+1} is re-rolled. A fixed cadence can be
forced via SCHEDULER_CONFIG.ROTATION.SIG_JINGLE_EVERY_SLOTS (overrides the
random range). Live verification: 7 airings per 120-track rotation with 8–20-track
gaps.
Why stochastic instead of fixed? A fixed "every 3rd slot" reads as a metronome to regular listeners — they can predict exactly when the jingle will hit. A uniform random gap in [2,5] keeps the expected cadence (3.5 slots ≈ every ~30–45 min) while destroying the pattern a listener could lock onto. The uniform range is wide enough to feel organic but bounded enough that the brand element still "fires often." Excluding the sig from the random jingles pool is what makes the cadence guaranteed: it can never be double-scheduled by the sampler, and it can never be absent because the pool didn't draw it.
16. Why the 0.5 gain everywhere?
Several corrections use a 0.5 multiplier — the anti-fatigue gain (§4), the crowd-bias half-boost (§5), the freshness flag energy term (§7). This is not a coincidence: 0.5 is the smallest "gentle step" that is still a real step. Any correction is a perturbation to a target, and a perturbation larger than ~0.5 tends to overshoot in the next decision, while one smaller converges too slowly. Using the same gain constant across independent subsystems keeps the whole scheduler's response feel consistent — every correction behaves like "lean gently, don't shove," which is the desired character for a radio that must stay listenable through any sequence of corrections.
17. Probability functions reference
Every probability distribution the engine draws from, with its sampling path. This is the "probability functions" companion to the two big distributions covered in §2 (Dirichlet) and §9 (Beta). See Probability & Bayesian Updating for the Bayesian interpretation and the update rules.
| Distribution | Used for | Shape of the engine's draw | Closed form |
|---|---|---|---|
| Uniform | Signature-jingle cadence gap (§15) | Math.random() over an integer range | |
| Normal | Box–Muller seed for Gamma/Beta samplers (§2, §9) | one pair per draw | |
| Gamma | Dirichlet concentrations (§2) and Beta ratio-of-gammas (§9) | Marsaglia–Tsang rejection sampler | |
| Dirichlet | Category pick in _pickMusicCategory (§2) | normalize independent Gammas | |
| Beta | Thompson arms (§9) | ratio of two Gammas | |
| Bernoulli | Reward signal for the arms (§9) | 0/1 success per on-air track |
Sampling chain. The draws are composed, not independent: Box–Muller normal → Marsaglia–Tsang Gamma → normalize (Dirichlet) or ratio (Beta). The whole chain is fast, seedable, and identical across the two distributions — one normal engine serves both.
Why the draws and not point estimates? Point estimates (always pick the max-weight category; always play the highest-scoring arm) lock the station into a single deterministic behavior. Distributional draws keep the long-run mean exact while letting individual rotations explore — the mechanism behind "same composition, different experience" (§2). The cost (a bit of variance) is negligible against the perceptual benefit of never sounding scripted.
18. Bayesian updating at LOKLOK
The engine's learning loop is already a textbook Bayesian filter — a conjugate Beta–Bernoulli model whose posterior doubles as the policy. See Probability & Bayesian Updating for the full treatment; the essential closed forms:
Prior — uniform before any data:
Posterior update — after a Bernoulli reward (listener growth or a hype vote) on the active arm:
Sampling policy (Thompson) — play the arm whose sampled posterior is largest:
Persistence — α, β are written to E:\radionew\models\rl_arms.json on
every update, so the belief state survives restarts. A restart does not reset
learning; it resumes the posterior where it left off.
What is NOT yet Bayesian. The NN/VAE track score (§7) outputs a point estimate, the genre weights (§2) use a static Dirichlet concentration rather than a learned posterior, and listener counts are consumed as a binary grew/dropped signal. §4–§5 of the Bayesian page propose Poisson–Gamma listener models, hierarchical daypart pooling, exponential evidence decay, and MC-dropout/GP track scoring — all backward-compatible upgrades of the same conjugate machinery.
Notation reference
| Symbol | Meaning |
|---|---|
| , | Integrated loudness (LUFS) |
| True-peak ceiling (dBTP) | |
| Loudness range | |
| Dirichlet weight / concentration for category | |
| Beta posterior parameters for RL arm | |
| Learning rate | |
| L2 weight-decay coefficient | |
| Crossfade duration (s) | |
| Bernoulli success probability sampled from Beta | |
| Bernoulli reward (1 = growth, 0 = drop) |