Skip to main content

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:

I=14.0 LUFSTP=1.5 dBTPLRA=11I = -14.0\ \text{LUFS} \qquad TP = -1.5\ \text{dBTP} \qquad LRA = 11

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 LL of the whole file, then applies

G=LtargetLmeasured,L=0.691+10log10 ⁣(1T0TyK2(t)dt)G = L_{target} - L_{measured}, \qquad L = -0.691 + 10 \log_{10}\!\left(\frac{1}{T}\int_0^T y_K^2(t)\, dt\right)

where yK(t)y_K(t) 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:

TP=20log10(maxnx4x[n])TP = 20 \log_{10}\left(\max_n \lvert x_{\uparrow 4x}[n]\rvert\right)

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 wcw_c is scaled into a concentration αc=wc6\alpha_c = w_c \cdot 6. A Dirichlet draw picks the category:

XcGamma(αc),pc=XcjXjX_c \sim \text{Gamma}(\alpha_c),\qquad p_c = \frac{X_c}{\sum_j X_j}

Implemented with the Marsaglia & Tsang gamma sampler, which in turn uses a Box–Muller normal pair:

Z=2lnU1cos(2πU2)Z = \sqrt{-2 \ln U_1}\cos(2\pi U_2)

Sampling instead of argmax gives the "smooth-but-varied" behavior: over many draws the mean is E[pc]=αc/jαj\mathbb{E}[p_c] = \alpha_c / \sum_j \alpha_j (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 αc=6wc\alpha_c = 6 w_c 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 αc\alpha_c, 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:

Windowarabic_hitsegyptian_trendsarab_technoenglish
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:

k(t)={deepnight02h<05morning05h<11midday11h<16evening16h<23latenightotherwisek(t) = \begin{cases} \text{deepnight} & 02 \le h < 05 \\ \text{morning} & 05 \le h < 11 \\ \text{midday} & 11 \le h < 16 \\ \text{evening} & 16 \le h < 23 \\ \text{latenight} & \text{otherwise} \end{cases}

and, when SCHEDULER_CONFIG.DAYPARTS is set,

k(t)=custom:startend:hard:weightsk(t) = \text{custom}:\text{start}{-}\text{end}:\text{hard}:\text{weights}

A rotation refresh is required iff the stamped key differs from the live key:

needsRefresh=(k(t)kstamped)consumedlength0.85\text{needsRefresh} = \left(k(t) \ne k_{stamped}\right) \lor \frac{\text{consumed}}{\text{length}} \ge 0.85

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 N=12N=12 played categories, the observed frequency p^c\hat p_c is compared to the expected weight wcw_c, and the weight is nudged toward the gap:

wc=max ⁣(0.02, wc+(wcp^c)0.5)w_c' = \max\!\left(0.02,\ w_c + (w_c - \hat p_c) \cdot 0.5\right)

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 (wcp^c)(w_c - \hat p_c), 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 \ge CROWD_SIZE_THRESHOLD (5):

warab_technowarab_techno(1+0.70),warabic_hitswarabic_hits(1+0.700.5)w_{arab\_techno} \leftarrow w_{arab\_techno}(1 + 0.70), \qquad w_{arabic\_hits} \leftarrow w_{arabic\_hits}(1 + 0.70 \cdot 0.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 ss over the 8 categories; music weights are nudged:

wcwc(0.7+0.6sc)w_c \leftarrow w_c \cdot (0.7 + 0.6\, s_c)

with sc[0,1]s_c \in [0,1] (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.jsdrawCard, TrackSuccessPredictor.

Each candidate in the pool (up to 6, all off-cooldown) gets a score; the best is selected:

score=0.7y^NN(c)+0.3sVAE,type(c)\text{score} = 0.7 \cdot \hat y_{NN}(c) + 0.3 \cdot s_{VAE,type}(c)

where y^NN\hat y_{NN} is the LSTM (6→16→1) success prediction for the track and sVAEs_{VAE} the VAE's affinity for that track type. Inputs to the LSTM are normalized features: category map mc[0,1]m_c \in [0,1], energy (1.0 for elements, 0.5 music), time of day h/24h/24, listener count min(u/50,1)\min(u/50, 1), 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.jsAudioFeatureClassifier, 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:

wijwijηLwijηλwij,λ=104w_{ij} \leftarrow w_{ij} - \eta \frac{\partial L}{\partial w_{ij}} - \eta \lambda w_{ij},\qquad \lambda = 10^{-4}

using LeakyReLU activation f(x)=xf(x) = x if x>0x > 0 else 0.01x0.01x.

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 λwij\lambda w_{ij} 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.jsReinforcementLearningEngine.

Three arms (CHILL / BALANCED / PARTY) hold Beta posteriors. Success/failure updates come from listener deltas and votes:

successα+=1 (or 2 on vote),failβ+=1\text{success} \Rightarrow \alpha \mathrel{+}= 1\ (\text{or }2\text{ on vote}), \qquad \text{fail} \Rightarrow \beta \mathrel{+}= 1

Each decision samples each arm's posterior and plays the argmax (Thompson sampling):

θaBeta(αa,βa),a=argmaxaθa\theta_a \sim \text{Beta}(\alpha_a, \beta_a), \qquad a^* = \arg\max_a \theta_a

The sampler uses the ratio-of-gammas identity: with Gα,GβG_\alpha, G_\beta independent gamma draws, Gα/(Gα+Gβ)BetaG_\alpha / (G_\alpha + G_\beta) \sim \text{Beta}. 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.jsisOnCooldown; smart-scheduler-parameters.js.

A track is ineligible if its last play is inside its window:

eligible    (ttlast)limit,limit={90 minsongs (COOLDOWN_PERIOD)10 minsfx/jingles/sweepers/ads/news (ELEMENT_COOLDOWN)\text{eligible} \iff (t - t_{last}) \ge \text{limit}, \qquad \text{limit} = \begin{cases} 90\ \text{min} & \text{songs (COOLDOWN\_PERIOD)} \\ 10\ \text{min} & \text{sfx/jingles/sweepers/ads/news (ELEMENT\_COOLDOWN)} \end{cases}

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.jsSCHEDULER_CONFIG.ROTATION.

The on-air rotation is rewritten once it is consumed enough:

consumedlengthREFRESH_FRACTION=0.85,lengthmax=BUFFER_MAX=120\frac{\text{consumed}}{\text{length}} \ge \text{REFRESH\_FRACTION} = 0.85, \qquad \text{length}_{\max} = \text{BUFFER\_MAX} = 120

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.liqcross(2.0s, ...), amplify(0.05, ...).

Songs transition with a 2-second crossfade (Δ=2\Delta = 2 s). The linear fade pair during the overlap is

fout(t)=1tΔ,fin(t)=tΔ,t[0,Δ]f_{out}(t) = 1 - \frac{t}{\Delta}, \qquad f_{in}(t) = \frac{t}{\Delta}, \qquad t \in [0, \Delta]

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

G=0.0526 dBG = 0.05 \approx -26\ \text{dB}

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:

dcross(t)={0.2 ssfx, shorts0.5 sjingles, sweepers, ads, news, talkshow, programs2.0 smusic, mashups (no override)d_{cross}(t) = \begin{cases} 0.2\ \text{s} & \text{sfx, shorts} \\ 0.5\ \text{s} & \text{jingles, sweepers, ads, news, talkshow, programs} \\ 2.0\ \text{s} & \text{music, mashups (no override)} \end{cases}

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:

σ2=1Ni(xixˉ)2,σ=σ2\sigma^2 = \frac{1}{N}\sum_i (x_i - \bar x)^2, \qquad \sigma = \sqrt{\sigma^2}

14. Mashup tempo lock

File: content-pipeline.js — mashup builder.

Sources must lock to the target BPM within ±4%, or the segment is rejected:

BPMsourceBPMtargetBPMtarget0.04\left|\frac{BPM_{source} - BPM_{target}}{BPM_{target}}\right| \le 0.04

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.jsupdateM3U (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:

sk+1=sk+U,UUniform{L,L+1,,H},L=2, H=5s_{k+1} = s_k + U, \qquad U \sim \text{Uniform}\{L, L+1, \dots, H\}, \qquad L = 2,\ H = 5

so the expected gap is E[U]=(L+H)/2=3.5\mathbb{E}[U] = (L+H)/2 = 3.5 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.

DistributionUsed forShape of the engine's drawClosed form
UniformSignature-jingle cadence gap (§15)Math.random() over an integer rangeUUniform{L,,H}U \sim \text{Uniform}\{L,\dots,H\}
NormalBox–Muller seed for Gamma/Beta samplers (§2, §9)one pair per drawZN(0,1)Z \sim \mathcal{N}(0,1)
GammaDirichlet concentrations (§2) and Beta ratio-of-gammas (§9)Marsaglia–Tsang rejection samplerXGamma(α)X \sim \text{Gamma}(\alpha)
DirichletCategory pick in _pickMusicCategory (§2)normalize independent Gammaspc=Xc/jXjp_c = X_c / \sum_j X_j
BetaThompson arms (§9)ratio of two Gammasθ=Gα/(Gα+Gβ)\theta = G_\alpha / (G_\alpha + G_\beta)
BernoulliReward signal for the arms (§9)0/1 success per on-air trackrBernoulli(θ)r \sim \text{Bernoulli}(\theta)

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:

Beta(αa,βa)=Beta(1,1)\text{Beta}(\alpha_a, \beta_a) = \text{Beta}(1, 1)

Posterior update — after a Bernoulli reward rr (listener growth or a hype vote) on the active arm:

Beta(αa+r, βa+1r),r=1 (growth), r=0 (drop)\text{Beta}(\alpha_a + r,\ \beta_a + 1 - r), \qquad r = 1\ (\text{growth}),\ r = 0\ (\text{drop})

Sampling policy (Thompson) — play the arm whose sampled posterior is largest:

θaBeta(αa,βa),a=argmaxaθa\theta_a \sim \text{Beta}(\alpha_a, \beta_a), \qquad a^* = \arg\max_a \theta_a

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

SymbolMeaning
LL, IIIntegrated loudness (LUFS)
TPTPTrue-peak ceiling (dBTP)
LRALRALoudness range
wcw_cDirichlet weight / concentration for category cc
αa,βa\alpha_a, \beta_aBeta posterior parameters for RL arm aa
η\etaLearning rate
λ\lambdaL2 weight-decay coefficient
Δ\DeltaCrossfade duration (s)
θ\thetaBernoulli success probability sampled from Beta
rrBernoulli reward (1 = growth, 0 = drop)