###### HALRAD 2026 ©️ - MBXHUB - Design

# How AutoQ Works: the Algorithm, the Tunings, and How It Contrasts With SMFM/SensMe



---

> **SMFM and coverage:** the fused mood model leans heavily on SMFM for valence. Tracks
> without SMFM data are not pushed through the fused head — they are served by a dedicated
> essentia-only head (section 2.2), so a library with little or no SMFM coverage keeps a
> genuine arousal read instead of collapsing toward the middle of the mood plane.

## 1. Pipeline at a glance

```
 Track file
    │
    ├─ Essentia scan (truedat, external) ──► mbxmoods.json  (features, keyed by full path)
    │                                              │
    │                                              ▼
    │                                       mood-cache lookup
    │                                    (only tracks that were scanned get an entry)
    │
    │        ┌─── cache HIT ──────────────────────────────────────┐
    │        │  valence/arousal computation                        │
    │        │    useTrainedModel=true  → trained Ridge model      │
    │        │    useTrainedModel=false → legacy weighted-sum      │
    │        └─────────────────────────────────────────────────────┘
    │        ┌─── cache MISS (unscanned track) ────────────────────┐
    │        │  fallback estimator                                  │
    │        │    genre → GenreMoodDefaults table (+ BPM/rating tint)│
    │        │    unmatched genre → neutral (0.5, 0.5)               │
    │        └──────────────────────────────────────────────────────┘
    │
    ▼
 (Valence, Arousal) per track  ──► mood-channel nearest-match (12 channels)
    │
    ▼
 9-factor linear score  — session taste, sentiment, recency/diversity penalties,
    exploration bonus, influence, mood match
    │
    ▼
 pick / refill  — PickMode (Off/Favorites/Weighted/Random) selects from the
    scored pool, diversity quota applied
    │
    ▼
 (Radio only) PFS ordering  — reorders the picked batch to glide toward
    a run center (seed centroid or mood target), Smooth/Wave flow, periodic slides
    │
    ▼
 queue the batch at the end of Now Playing
```

Two independent tracks feed the (Valence, Arousal) plane: **AutoQ** (Essentia audio
features → trained Ridge model) and **SMFM/SensMe** (Sony's embedded 10-channel STMO
scores → linear projection). Section 6 covers how/whether they combine.

---

## 2. V/A estimation: trained model vs. formula fallback

### 2.1 The switch

Every valence/arousal computation starts with a short-circuit: if the trained model is
enabled and loaded, the prediction comes from the Ridge model; otherwise the weighted-sum
formula (section 2.3) runs.

`autoQ.useTrainedModel` defaults to **`true`** — new installs run the trained model out of
the box. Tracks without Essentia features still fall back to the genre-defaults estimator
(section 2.4) regardless of this setting.

### 2.2 Trained-model path

The embedded model is a **Ridge regression trained on 180 hand-rated anchor tracks**:

- 24 input features, standardized (`(x - mean) / std` per a model-carried scaler), with a
  model-carried **impute block** — missing `smfmValence`/`smfmArousal`/`modeMajor` substitute
  the training-corpus column mean, identically at train and serve time.
- Two independent linear axes (valence, arousal), each `coef · x_standardized + intercept`,
  then a **spread-stretch** around a fitted center: a Ridge fit regresses to the mean, so raw
  output shrinks toward the intercept; the stretch restores range without changing rank order.
- Leave-one-out cross-validation correlation, served by `/vam/model/info`:
  **valence r=0.86, arousal r=0.98**. Arousal is the more reliable axis — see section 7(c)
  for why valence is structurally harder.
- **Essentia-only fallback head**: the model file carries a second head, `essentiaOnly`,
  trained on the 22 non-SMFM features. Serve time picks per track: SMFM data present → the
  fused 24-feature head; absent → the essentia-only head, instead of mean-imputing the two
  SMFM columns. A no-SMFM track therefore gets a real read (essentia-only arousal r=0.81;
  valence r=0.32 — honest but weak: the fused head's SMFM lean is genuinely load-bearing
  for valence). `/vam/model/info` exposes `essentiaOnlyHead` plus both heads' r values.

The 24 features: spectralCentroid, spectralFlux, spectralFlatness,
danceability, dissonance, pitchSalience, chordsChangesRate, spectralSkewness,
spectralEntropy, spectralComplexity, hpcpCrest, hpcpEntropy, hfc, beatsLoudness,
chordsStrength, bpm, loudness, onsetRate, zeroCrossingRate, spectralRms, dynamicRange,
**smfmValence, smfmArousal, modeMajor**. The smfm pair is the SMFM/SensMe projection
(section 6); `modeMajor` encodes musical mode (major=1.0 / minor=0.0, unknown mean-imputed).
SMFM valence is not a minor input: its valence coefficient (**+0.192**) is the largest
single valence weight, and the SMFM arousal coefficient is +0.163. `modeMajor`'s direct
coefficient is small (+0.004) because the mode signal mostly rides in through SMFM valence,
which itself tracks mode strongly.

> Retraining happens outside the running hub: a replacement model is trained from the
> `GET /vam/train/moods` export and gated against the embedded baseline; a candidate
> promotes by dropping it at `%AppData%\MBXHub\mood-model.json` + `POST /vam/model/reload`
> — which also recomputes the mood cache in place, so no restart.

A track with no essentia scan (no `mbxmoods.json` entry) never reaches this path — see 2.4.

### 2.3 Formula fallback (weighted sum)

When `useTrainedModel=false`, valence and arousal are each a hand-tuned weighted sum of
normalized Essentia features, genre-adjusted, then stretched and clamped:

```
valenceRaw = ValenceIntercept
           + wMode·modeScore + wDance·danceNorm + wFlat·flatnessNorm
           + wDiss·dissonanceNorm + wSal·salienceNorm + wChords·chordsNorm
           + wMfcc·mfccNorm + wCent·centroidNorm + wSkew·skewnessNorm
           + wChordsStr·chordsStrengthNorm + wHpcpEnt·hpcpEntropyNorm
           + wHpcpCrest·hpcpCrestNorm + wHfcV·hfcNormForV + wSpecEnt·spectralEntropyNorm

valence = clamp01( 0.5 + (valenceRaw - 0.5) * 1.5 )   // range-stretch
```

Each `w*` is first multiplied by the genre profile's corresponding multiplier
(section 2.5). Current base weights:

| Valence weight                                                      | Default | Notes                                                                                |
| ------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------ |
| `ValenceWeightMode`                                                 | 0.30    | major/minor; kept moderate — a heavier mode weight over-fits major-key sad ballads   |
| `ValenceWeightChordsStrength`                                       | 0.20    | separates melodic from atonal/distorted music                                        |
| `ValenceWeightSpectralSkewness`                                     | 0.20    | right-skewed spectrum reads positive; correlates ≈ +0.46 with rated valence          |
| `ValenceWeightCentroid`                                             | 0.10    | kept low — an uninverted centroid pulls bright/distorted metal toward high valence   |
| `ValenceWeightDissonance`                                           | 0.10    | kept low — DEAM-corpus correlations don't generalize to a metal-heavy real library   |
| `ValenceWeightChords`                                               | 0.10    | chord-change rate, distinct from chord-detection strength                            |
| `ValenceWeightDance`, `Flatness`, `PitchSalience`, `Mfcc`           | 0.0     | dead — measured correlation ≈0 or always-zero source signal                          |
| Extras (`HpcpEntropy`, `HpcpCrest`, `Hfc`, `SpectralEntropy`)       | 0.0     | wired in but weight left at 0 pending calibration                                    |

| Arousal weight                               | Default     | Notes                                                                       |
| -------------------------------------------- | ----------- | --------------------------------------------------------------------------- |
| `ArousalWeightFlux`                          | 0.20        | primary energy signal                                                       |
| `ArousalWeightBpm`, `ArousalWeightCentroid`  | 0.15 each   | BPM noisy for thrash/sludge; centroid = distortion/cymbal cue               |
| `ArousalWeightLoudness`, `ArousalWeightRms`  | 0.15 / 0.10 | scaled by a dynamic-range multiplier (see below), not raw                   |
| `ArousalWeightZcr`, `ArousalWeightOnsetRate` | 0.10 each   |                                                                             |
| `ArousalWeightDance`                         | 0.05        |                                                                             |
| `ArousalWeightDynamicRange`                  | 0.0         | dead as an *additive* term — DR works as a *multiplier* instead (below)     |

Two corrective multipliers run before the sum:

- **DR-as-multiplier**: `drMult = 0.5 + 0.5·clamp01(drNorm·18/8)`; `loudEff = loudNorm·drMult`,
  `rmsEff = rmsNorm·drMult`. Brick-walled/crushed masters (low dynamic range) get damped
  loudness credit instead of inflated arousal — a compressed pop master doesn't read as
  "more energetic" just because it's loud on every sample.
- **BPM-doubling guard**: if `bpmNorm > 0.9` and `onsetNorm < 0.5`, `bpmEff = bpmNorm·0.5`
  — catches Essentia's beat tracker doubling on off-beat sub-pulses (a real example: Radiohead
  "Nice Dream" measured 172 BPM, perceptually ~85).

Arousal uses the same 1.5x stretch-around-0.5 as valence. The stretch is deliberately
linear, not a sigmoid: a sigmoid *compresses* toward 0.5 — the opposite of the intended
effect, and a classic cause of "everything pinned to the middle."

### 2.4 Fallback estimator — what unscanned tracks get

The fallback estimator runs when a track has **no** `mbxmoods.json` entry at all (never
Essentia-scanned) — the mood cache is populated only from `mbxmoods.json`'s `tracks`
object, so any path not present in it has no feature data. This is **not** the trained
model with zeroed features — it's a completely separate, cheap estimator:

1. Start at neutral `(0.5, 0.5)`.
2. Look up the track's genre string in `GenreMoodDefaults` (exact match, then
   token-split match — "Heavy Metal" → "Metal"). If found, valence/arousal become that
   genre's curated defaults.
3. Blend in BPM (30% weight if a genre match was found, 100% if not) and rating (same
   30%/100% split) as secondary tints.
4. Small year tint (±0.05 arousal max).

If the genre string is **empty, unrecognized, or doesn't token-match** any entry in
`GenreMoodDefaults`, the track stays at neutral `(0.5, 0.5)` — see section 4e for why that
matters.

### 2.5 Genre adjustment

Built-in genre weight profiles multiply the base weights above per genre family before the
sum. Selected examples:

- **Metal / British Metal / Hardcore**: `Loudness=1.5, Centroid=1.8` (arousal amplified —
  loud+bright *is* the intensity signal), but `Mode=0.5, Dissonance=0.2, ValCentroid=0.2,
  Skewness=0.5, Chords=0.3` (valence-side dampened hard — loud+bright is texture, not
  happiness), plus an additive `ValenceBias=-0.25`.
- **Electronic/Dance/House/Techno**: `Bpm=0.6` (uniformly high, uninformative),
  `Loudness=1.4, Flux=1.3` (these carry the signal instead).
- **Jazz/Blues**: `Bpm=1.5, Chords=1.8, Loudness=0.6` — harmony matters more than tempo/volume.
- **Classical/Ambient**: `Flux=1.5, Loudness=0.5, Dance=0.3, Mode=1.3`.

`autoQ.estimation.useGenreAdjustment` (default `true`) gates this globally;
`GenreProfiles` (per-user override dict) takes precedence over the built-ins.

One asymmetry worth knowing: the hand-authored per-genre `ValenceBias` values are live in
formula scoring (applied as an additive pre-clamp offset), but the anchor-calibration fit
treats bias as frozen — it zeroes bias while fitting and restores it afterward, so
calibration tunes the weights around whatever bias is already set and never learns new
bias values.

### 2.6 GenreMoodDefaults — the Day-1 tuning surface

The built-in `GenreMoodDefaults` table is what section 2.4's fallback reads. Selected
entries relevant to the "chill drift" symptom:

| Genre     | Valence | Arousal |
| --------- | ------- | ------- |
| Metal     | 0.35    | 0.90    |
| Hardcore  | 0.30    | 0.90    |
| Punk      | 0.55    | 0.85    |
| Hard Rock | 0.50    | 0.80    |
| Rock      | 0.60    | 0.70    |
| Ambient   | 0.50    | 0.15    |
| Jazz      | 0.55    | 0.35    |
| Classical | 0.55    | 0.30    |

A correctly-tagged "Metal" track lands in the high-arousal, low-valence quadrant (near the
Relentless/Edge/Intense channels below), not chill — **provided the genre string round-trips
through the table.** Untagged, blank, or exotic subgenre strings that don't token-match fall
through to neutral (0.5, 0.5), which is numerically closer to Drive/Chill/Emotional than to
any high-arousal channel (see 4e for the distance math).

---

## 3. Mood channels — nearest-match

AutoQ ships 12 named channels, each a (Valence, Arousal) point:

| Channel    | Emoji | Arousal | Valence |
| ---------- | ----- | ------- | ------- |
| Relentless | 💀    | 0.80    | 0.35    |
| Intense    | ⚡     | 0.72    | 0.20    |
| Energetic  | 🔥    | 0.80    | 0.61    |
| Drive      | 🕺    | 0.67    | 0.66    |
| Edge       | 🤘    | 0.60    | 0.19    |
| Upbeat     | 🎉    | 0.55    | 0.79    |
| Morning    | 🌞    | 0.45    | 0.78    |
| Emotional  | 😱    | 0.40    | 0.25    |
| Chill      | 😊    | 0.37    | 0.70    |
| Mellow     | ☁️    | 0.25    | 0.46    |
| Night      | 🌙    | 0.15    | 0.28    |
| Relax      | 🧘    | 0.10    | 0.58    |

The channel arousal centers span 0.10–0.80 rather than the full unit range because real
Essentia-derived arousal (a weighted average of ~9 normalized signals) empirically clusters
in the 0.3–0.7 band — even a top-percentile thrash track lands around 0.65–0.72, so
channels placed at 0.85–0.95 would be unreachable in practice.

**Matching**: Euclidean distance in the (Valence, Arousal) unit square, similarity =
`1 - dist/√2`, clamped to ≥0. A track's top match(es) are channels scoring above
`MoodMatchMinSimilarity` (default 0.5); a **second** channel is included only if its score
≥ `primary·MoodComboMinScore` (default 0.92) — this is the "😌 Chill / 😢 Emotional"
combo-label mechanism.

---

## 4. Candidate selection + scoring

### 4a. Candidate pool

Source: the MBXQ shuffle's unplayed set if available, else the **entire cached library
file list**. Filtered by: global ban list, session bans, and two operator filters:
`autoQ.maxTrackLengthSeconds` (0 = off; unknown duration = keep) and `autoQ.filter`
(a search-DSL expression evaluated in-process; missing data never matches a predicate,
unknown duration always kept). Both join the cache key.
**No essentia-scan-status filter exists anywhere in this path** — scanned and unscanned
tracks compete in the same pool on equal footing. Cached with a version-stamp fast path
(rebuilt only when unplayed/ban-list/session-ban versions — or either filter — change).

A third, run-scoped layer: a saved station can carry its own `filter`, applied live in
the vibe-list return path while that station is on-air. It COMPOSES with the global filter
(a candidate must pass both); the cached pool and vibe list stay run-agnostic.

### 4b. Scoring

```
score = α₁·featureSim + α₂·trackSentiment + α₃·artistSentiment
      − α₄·recencyPenalty − α₅·diversityPenalty
      + α₆·exploreBonus + α₇·influenceScore + moodMatchWeight·moodMatch
      + unheardBonus            (fixed +1.0, only if Shuffle.PlayUnheardFirst is on)
```

Default weights: `α₁ FeatureSimilarity=0.5`, `α₂ TrackSentiment=0.25`,
`α₃ ArtistSentiment=0.15`, `α₄ RecencyPenalty=0.3`, `α₅ DiversityPenalty=0.6`,
`α₆ ExplorationBonus=0.1`, `α₇ InfluenceWeight=0.3`, `MoodMatchWeight=0.4` (a separate
setting). Diversity-quota breach is a **hard block** (`diversityPenalty >= 1.0` → the
track is excluded entirely), not a soft penalty.

**Factor 1, `featureSim`, is gated:** feature similarity (cosine similarity between the
session taste vector and the track's features) is computed only when the session has a
taste vector — and that exists **only after at least one reaction has been recorded this
session.** With zero reactions, `featureSim = 0` for every candidate, unconditionally.

**Factors 2/3, sentiment, come from the recorded reactions** — also zero with no
reactions (an empty reaction store yields 0 for every track and artist).

**Factor 6, `exploreBonus`** = `1/√(1+playCount)` — for any track with zero lifetime plays
(the overwhelming majority of a large, mostly-unplayed library), this is exactly **1.0**.

**Net effect in a quiet session (no reactions, mood mode off, no influences configured):**
`score ≈ α₆ · 1.0 = 0.1 · 1.0 = 0.1` for nearly every unplayed candidate — a **flat,
constant score**. Two mechanisms keep that from degenerating into an arbitrary, repeating
slice of the library:

- **Tie-break shuffle.** The vibe-list refresh runs the candidate array through a
  Fisher-Yates copy-shuffle before scoring. A flat-scored session's ties still exist, but
  the selection heap's keep-first bias applies to a shuffled order instead of the
  library's enumeration order — the vibe list's top-N slice varies refresh to refresh
  instead of pinning to the same head-of-library handful. Taste-rich sessions are
  unaffected: real score differences still dominate; only ties move.
- **Follow-the-music center.** The refresh also narrows the pool toward a center before
  the shuffle. When a radio run has an explicit center (set when the run is configured —
  sections 4e/5.1), that center is used. When there is **no** explicit run center — the
  default continuous queue top-up most installs run under, since radio behavior is active
  whenever AutoQ is enabled and `PickMode` isn't `Off` (section 5.1) — a center is derived
  on the fly: the now-playing track's scanned V/A if it has one, else the scanned centroid
  of the queue-ahead window, else nothing (the candidate set stays unfiltered). Cache-only
  lookups only — genre/neutral estimates are never used here, because they would drag the
  center back to (0.5, 0.5) and reintroduce the chill drift section 4e describes. The
  center is recomputed every fill, so upcoming picks track the music as it moves, with no
  explicit radio start required. This doesn't add real ranking signal to a flat-scored
  pool — it narrows *which* tracks are even in the pool before the flat score and the
  shuffle take over, which is what makes upcoming tracks feel related to what's playing
  instead of the fill scoring the whole library uniformly.

### 4c. Same-few-artists dynamics

The diversity pass enforces `ArtistQuota` (default **1**) and `GenreQuota` (default **3**)
over a rolling window, seeded from tracks already queued ahead. This caps repeats *within
one selection pass/window*, but does **not** constrain *which* artists dominate pass after
pass: when the underlying score is flat (4b), the pool the diversity-cap greedily walks is
effectively tie-break order.

Because that order is reshuffled every refresh (4b), a flat-scored pool's top-of-heap
slice is a fresh random draw each time rather than a pinned head-of-library handful. This
is a tie-break property, not a ranking one: within one refresh the diversity cap still
only constrains repeats inside that pass, and a session with genuinely differentiated
scores (reactions recorded) resolves by score, not by shuffle order.

### 4d. Generate-from-seeds ("like these seeds")

The pool for seed-based generation is the **scanned candidate set**: every ban-respecting
library candidate that has real scanned V/A — taste score and diversity caps never touch
it. The generator runs the funnel-pool selector against the seed centroid (the same
widening-to-`FunnelMinPoolTracks` and mostly-unscanned fallback as the radio fill's
funnel), ranks the funnel's survivors by proximity to the centroid, takes the nearest
`count*2`, PFS-orders them, and truncates to `count`. A quiet, taste-flat session does not
constrain which tracks are eligible for a generate list — the corridor and proximity
ranking pick against the *whole* scanned library, so a seed toward a rarely-played mood
corner can still find its true nearest neighbors. Thin scanned coverage still yields a
shorter list (the funnel's widening loop can't invent scanned tracks that don't exist) —
that's coverage-limited, not a taste-pool substitution.

Journey generation shares this pool: the scanned candidate set is filtered against the
route polyline instead of a single center.

Connect-from-queue is the same journey path with the waypoint set sourced differently:
instead of an explicit `waypoints` body, it reads the Now Playing queue-ahead window (the
2-3 tracks already queued next) as the route's waypoints, then splices the generated arc
into the queue in that window's place — removing the original waypoint tracks and
inserting the arc next, so the user's own picks still open and close the journey. Count
derivation is identical to `/autoq/radio/connect`: an explicit count clamps to 5-50, an
omitted one derives from route length via `clamp(round(pathLength/0.06), 15, 30)`.

### 4e. Why a metal seed can drift toward chill

A radio run's `center` is computed from the seed set as a plain average of each seed's
estimated mood. Two independent paths lead a "metal seed" toward a chill-region center:

1. **Seed resolution fallback.** If nothing is queued ahead, the seed set falls back to a
   single now-playing track. If that one track has no essentia scan, its mood comes from
   the fallback estimator (2.4 above) — genre defaults correctly place "Metal" at
   (0.35, 0.90), but any genre string that doesn't token-match (blank tag, unusual
   subgenre spelling, a genre field MusicBee populated from a different vocabulary) falls
   through to neutral (0.5, 0.5).
2. **Centroid math with a mixed/neutral seed set.** The centroid is a plain average — if
   even one seed track resolves to neutral (0.5, 0.5) while others resolve correctly, the
   center is pulled toward the middle of the V/A square. Euclidean distance from (0.5, 0.5)
   to each channel (computed from the table in section 3): **Drive is closest (0.234)**,
   **Chill second (0.240)**, then Emotional (0.269) and Mellow (0.253) — all well ahead of
   the actually-metal channels Relentless (0.335), Edge (0.326), or Intense (0.372). A
   neutral-fallback center is numerically pulled toward Drive/Chill/Mellow, not toward
   aggressive metal — which matches the reported symptom exactly.

Once the PFS pass runs, every subsequent pick in that radio run is ordered by nearness to
this same `center` (glide) and, less strongly, nearness to it as the flow target (Smooth
flow holds at center permanently) — so a neutral-drifted center doesn't just mis-seed
once, it steers the whole run.

### 4f. Send-to-AutoQ steering

`POST /autoq/send` re-anchors a running radio's seed centroid on the just-sent tracks
rather than replacing the whole scoring/candidate machinery above — it feeds a new seed
generation into the same `center` that 4e's centroid math and the PFS glide both key off.

**Generation-weighted centroid.** Each send appends one seed generation (its batch of
URLs) to the run's seed history, oldest generation first. The center is a weighted average
over generations, not tracks: the newest generation (age 0) always carries weight `1`, and
each older generation `i` steps back is discounted by `(1 - weight)^i`. At `weight = 0`
this degenerates to a flat, un-aged centroid over every seed track ever sent (Reuse); at
`weight = 1` only the newest generation survives the sum at all (Restart) — `weight >= 1.0`
is special-cased to drop prior generations outright rather than relying on the decay
reaching zero.

**Mode presets on one continuous axis**: `reuse` → weight `0`, `restart` → weight `1`,
`refresh` → the configured `sendRefreshWeight` (default `0.7`). The three UI-facing modes
are just named points on this axis — there's no separate code path per mode, only a
different decay constant fed into the same centroid math.

**Seed eviction.** Cumulative modes (`weight < 1`) keep appending generations until the
total seed-track count exceeds `sendMaxSeeds` (default `12`), then evict whole generations
oldest-first until back under the bound. The newest generation is never evicted — it's the
batch the user just sent — so a `sendMaxSeeds` smaller than one batch still keeps that
batch intact and simply drops everything older.

**Per-send steering happens at the funnel-pool center**: the re-anchor swaps in the new
generation list and center, and the next fill/PFS pass (4b, 5.2) is what actually changes
which tracks get picked — the send queues the sent batch immediately (per
`sendQueueCap`/`sendQueuePlacement`) but does not itself alter already-queued fills unless
`sendRefill=replace` explicitly re-picks the unplayed fills behind the send against the
new center.

**Journey re-splice mechanics.** A send while a journey is in flight re-routes the
*remaining* arc rather than touching the seed centroid: up to `sendWaypointCap` (clamped
1-4) scannable sent tracks become new pinned waypoints, appended before the existing
stamped destination — the engine hard cap is 4 sent waypoints plus the destination = 5
pinned points total per arc. Unscanned sent tracks (no V/A data) can't pin a waypoint;
they either queue as plain overflow or get dropped per `sendWaypointOverflow`. Arc size
between waypoints is the same distance-derived default the `/autoq/radio/connect` family
uses: `clamp(round(pathLength / 0.06), 15, 30)` tracks, where `pathLength` is the summed
Euclidean V/A distance between consecutive waypoints. The journey is considered "in
flight" only while the run's stamped destination is still ahead in the queue — a
destination that already played, or was consumed by a queue edit, degrades a later send to
steer/start instead of waypoint; the stamp itself is only ever cleared by the run
lifecycle, never by a failed lookup.

---

## 5. AutoQ-Radio + PFS ordering

### 5.1 Run lifecycle

Starting a radio: optionally clears the queue (`Fresh`), resolves pick mode, marks the run
started, configures the run source (sets seeds + center), fills a batch, then auto-plays
if the transport wasn't already playing.

Seed source: seeds = next `QueueThreshold` (default 3) tracks already queued ahead,
falling back to now-playing if nothing is queued (4e above). Mood source: center = the
active `TargetMood`'s (Valence, Arousal) directly, no seed averaging.

### 5.2 PFS ordering

PFS reorders an already-*selected* batch (selection is untouched) so consecutive picks
form a sensible listening arc. Per pick:

```
target = center                                           (Smooth)
target.Arousal = clamp01(center.Arousal
                  + WaveDepth · sin(2π·(i+phaseOffset)/WavePeriod))   (Wave)

isSlide = SlideEvery > 0 && (i+phaseOffset+1) % SlideEvery == 0
candidatePool = isSlide ? {c : dist(c, prev) ≥ SlideReach} : all-remaining
pick = argmin_c [ GlideTightness · dist(c, prev) + (1-GlideTightness) · dist(c, target) ]
dist(a,b) = √( (Δvalence)² + (Δarousal)² + 0.5·(ΔbpmNorm)² )
```

Tracks with no analyzed mood data can't be placed meaningfully and are appended at the end
in original order. `FlowKind.Smooth` (default) holds `target = center` for the whole run —
pure cohesion, no arc. `FlowKind.Wave` cycles arousal above/below center on a period
(Build/Wind-down flow kinds are designed but not yet implemented).

### 5.3 The tunable knobs

| Setting          | Default | Effect                                                                                                                                                                                   |
| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GlideTightness` | 0.6     | Weight of "stay near the previous pick" vs "head toward the target" in each PFS choice. 1.0 = pure glide (ignores target entirely once moving); 0.0 = pure beeline to target every pick. |
| `SlideEvery`     | 10      | Every Kth pick is forced to be ≥`SlideReach` away from the previous track — a deliberate lateral step so the run doesn't stagnate in one micro-neighborhood. 0 disables slides.          |
| `SlideReach`     | 0.35    | Minimum V/A-space distance (same metric as `dist()` above) a slide candidate must clear.                                                                                                 |

All three are read live from settings on every ordering pass — changes to
`/autoq/settings` apply to the *next* fill, no restart needed.

---

## 6. SMFM/SensMe contrast

|                      | AutoQ                                                                                                                           | SMFM/SensMe                                                                                                                                                                                                                                              |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Source signal**    | Essentia audio-feature extraction (spectral/rhythm/harmony stats) run by `truedat`, stored in `mbxmoods.json`                   | Sony Music Center's proprietary 10-channel STMO analysis, embedded in the file's own tags by Sony hardware/software at rip/transfer time                                                                                                                  |
| **Coverage**         | Only tracks explicitly Essentia-scanned                                                                                         | Only tracks that passed through Sony Music Center / a Walkman-family device that wrote the STMO block                                                                                                                                                     |
| **V/A derivation**   | Trained Ridge regression (24 features → 2 axes) *or* hand-tuned weighted-sum formula, both retrainable/tunable                  | Fixed linear projection: z-score the 10 raw 0–255 STMO scores against a fitted mean/std, dot with fitted arousal/valence coefficient vectors, linear-rescale to [0,1] via fixed scale bounds. Coefficients were fitted once (unsupervised PCA over a ~6,756-track corpus) and are baked in — not retrainable. |
| **Interpretability** | Every feature and weight is named and documented                                                                                | The 10 STMO channels' underlying meaning is Sony-proprietary and firmware-only — SMFM is used as an opaque feature vector, not a labeled one                                                                                                              |
| **Where it's blind** | Silent on unscanned tracks (falls back to genre defaults, section 2.4)                                                          | Silent on tracks never processed by Sony software — most of a typical non-Sony library                                                                                                                                                                   |
| **Where it wins**    | Full library coverage once scanned; explainable, tunable, calibratable against listener-rated anchors                           | Zero-cost mood signal for tracks that already carry the tag — no local scan needed                                                                                                                                                                       |

### 6.1 Fusion vs. "effective" V/A — two different mechanisms, don't conflate them

1. **Model-level fusion (permanent, structural):** the trained Ridge model (section 2.2)
   includes `smfmValence`/`smfmArousal` as 2 of its 24 input features — and for valence
   the SMFM coefficient is the single largest weight. This happens automatically whenever
   `useTrainedModel=true`, no user action required, and is baked into the single (Valence,
   Arousal) the rest of AutoQ (scoring, mood-channel matching, PFS) consumes. Tracks
   without SMFM data bypass this entirely via the essentia-only head (section 2.2).

2. **Read-time "effective" V/A blend (display-only, separate mechanism):** REST responses
   can carry `effectiveArousal`/`effectiveValence` — a **per-axis weighted blend** of the
   raw AutoQ value and the raw SMFM projection: `eff = wSmfm·smfm + (1-wSmfm)·autoq` —
   but **only when at least one source-duel judgement has been recorded for that axis**
   ("which source is closer to what I hear", recorded via the source-duel UI). With zero
   duels, `effA=aqA` and `effV=aqV` exactly — pure AutoQ, unchanged. `wSmfm` is a
   Laplace-smoothed SMFM win-rate: `(smfmWins+1)/(smfmWins+autoqWins+2)`, "same" votes
   counting half a win each side. This blend **does not drive track selection** — the
   fused AutoQ model is the sole selection source — it only affects what's *displayed* as
   the "effective" number on diagnostic/tuning surfaces.

So: SMFM has a fixed, structural influence on AutoQ's own predictions (via the trained
model's coefficients); it additionally has an optional, operator-taught, cosmetic
influence on what number gets displayed as "effective" — the two never combine or
double-count because the second only exists on REST response dictionaries, never in the
scoring/ordering path.

---

## 7. Why picks can feel wrong — summary

**(a) Same-few-artists clustering.** Diversity quotas (`ArtistQuota=1`, `GenreQuota=3`)
cap repeats *within* a selection window but can't rank a *flat-scored* pool (section 4b):
with no reactions, `featureSim`, `trackSentiment`, and `artistSentiment` are all
structurally zero, leaving `exploreBonus` (constant 1.0 for any never-played track ×
weight 0.1) as effectively the only signal — a tie, not a ranking. The per-refresh
tie-break shuffle and the follow-the-music pool narrowing (sections 4b/4c) keep that tie
from resolving to the same head-of-library handful every pass, and make upcoming picks
relate to the music instead of the fill scoring the whole library uniformly. But those are
tie-break and pool mechanisms, not a taste *ranking*: in a flat-scored session, picks are
diverse-random, not diverse-by-relevance — that gap only closes as reactions accumulate
and `featureSim` etc. become non-zero.

**(b) Chill-drift on an unscanned library / no reactions.** Two independent causes stack:
(1) unscanned tracks in the candidate pool contribute *no scoring signal at all* beyond the
flat explore bonus — they aren't excluded from the pool (section 4a: no scan-status
filter exists), they're just scored identically to everything else; (2) a radio run's PFS
`center` is a plain average of seed V/A, and any seed that resolves to the neutral (0.5, 0.5)
fallback (blank/unmatched genre tag, section 2.4) pulls that average toward the numerically
nearest channels — which are Drive, Chill, and Mellow, not the aggressive end of the chart
(section 4e). A single neutral-fallback seed among a metal seed window is enough to visibly
bend the run's whole trajectory, because every subsequent PFS pick glides toward that same
pulled center.

**(c) Low-valence assignments on tracks a human reads as positive.** Bounded by model
accuracy: the shipped Ridge model's valence LOO-CV correlation is **r≈0.86** (served by
`/vam/model/info`) — strong, but still short of arousal's **r≈0.98**. This is a known,
structural limitation, not a bug: valence (happy/sad) is a much weaker function of
spectral/rhythm features than arousal (energy) is — timbre and loudness map fairly
directly to perceived energy, but "happy vs. sad" leans on harmonic/lyrical/cultural cues
that a pure-audio feature model captures only partially. The formula-fallback path's
genre-bias and dampening machinery (section 2.5, e.g. Metal's `Mode=0.5, Dissonance=0.2`
dampening) exists specifically to correct for the biggest known failure mode
(bright+loud+distorted reading as "happy") but is a hand-tuned patch, not a fix to the
underlying correlation ceiling.

---

## 8. Tunings reference table

All settings live under `autoQ.*` in `mbxhub.json`, editable via `/autoq/settings` or the
AutoQ tuning console (`autoq.html`).

| Setting                                        | Path                                              | Default                       | What it actually changes                                                                                                                                                                                                      |
| ---------------------------------------------- | ------------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UseTrainedModel`                              | `autoQ.useTrainedModel`                           | `true`                        | Switches every V/A computation between the Ridge model (2.2) and the weighted-sum formula (2.3). Also gates whether the formula sliders/genre-bias/Calibrate button have any live effect (they only affect the formula path). |
| `PickMode`                                     | `autoQ.pickMode`                                  | `Weighted`                    | `Off` = shuffle only. `Favorites` = deterministic top-score. `Weighted` = score-proportional random from top 5 (`weights[i] = (score-minScore)+0.1`). `Random` = uniform, diversity caps still apply.                         |
| `QueueThreshold`                               | `autoQ.queueThreshold`                            | 3                             | Refill trigger; also the seed-window size (how many queued-ahead tracks seed a Seed-source radio start).                                                                                                                      |
| `BatchSize`                                    | `autoQ.batchSize`                                 | 5                             | Tracks added per refill.                                                                                                                                                                                                      |
| `VibeListSize`                                 | `autoQ.vibeListSize`                              | 100                           | Size of the scored/diversity-capped candidate pool everything else draws from.                                                                                                                                                |
| `VibeListRefreshMinutes`                       | `autoQ.vibeListRefreshMinutes`                    | 30                            | Cache lifetime before a full rescore.                                                                                                                                                                                         |
| `ArtistQuota`                                  | `autoQ.artistQuota`                               | 1                             | Max same-artist tracks per diversity window/batch; 0 disables (hard block at breach, 4c).                                                                                                                                     |
| `GenreQuota`                                   | `autoQ.genreQuota`                                | 3                             | Same, per genre.                                                                                                                                                                                                              |
| `ExcludeThreshold`                             | `autoQ.excludeThreshold`                          | -5                            | Score at/below which a track is excluded outright.                                                                                                                                                                            |
| `GlideTightness`                               | `autoQ.glideTightness`                            | 0.6                           | PFS: previous-track nearness vs. target nearness weight per pick (5.2/5.3).                                                                                                                                                   |
| `SlideEvery`                                   | `autoQ.slideEvery`                                | 10                            | PFS: forces a lateral jump every Kth pick; 0 = off.                                                                                                                                                                           |
| `SlideReach`                                   | `autoQ.slideReach`                                | 0.35                          | PFS: minimum distance a slide candidate must clear.                                                                                                                                                                           |
| `MoodMatchWeight`                              | `autoQ.moodMatchWeight`                           | 0.4                           | Scoring weight for proximity to `TargetMood` (only active in Mood mode).                                                                                                                                                      |
| `ScoringWeights.*` (α₁–α₇)                     | `autoQ.scoringWeights.*`                          | 0.5/0.25/0.15/0.3/0.6/0.1/0.3 | The 4b linear-score coefficients — feature similarity, track sentiment, artist sentiment, recency penalty, diversity penalty, exploration bonus, influence weight.                                                            |
| `Estimation.ValenceWeight*` / `ArousalWeight*` | `autoQ.estimation.*`                              | see §2.3 tables               | Formula-path per-feature weights. Only live when `UseTrainedModel=false`.                                                                                                                                                     |
| `Estimation.GenreProfiles`                     | `autoQ.estimation.genreProfiles`                  | built-ins (§2.5)              | Per-genre weight multipliers + `ValenceBias`, formula path only.                                                                                                                                                              |
| `GenreMoodDefaults`                            | `autoQ.genreMoodDefaults`                         | curated table (§2.6)          | Fallback (0.5,0.5)-avoidance table for unscanned tracks — the single highest-leverage fix for symptom (b) is adding entries here for any genre string your library actually uses that isn't already covered.                  |
| `Estimation.UseGenreAdjustment`                | `autoQ.estimation.useGenreAdjustment`             | `true`                        | Global on/off for §2.5 multipliers.                                                                                                                                                                                           |
| `Estimation.UsePercentileNormalization`        | `autoQ.estimation.usePercentileNormalization`     | `true`                        | Ranks features against the live library distribution instead of fixed min/max ranges.                                                                                                                                         |
| `RecencyPenaltyDecay` / `MinReplayMinutes`     | `autoQ.recencyPenaltyDecay` / `.minReplayMinutes` | 0.1 / 30                      | Exponential decay rate and hard-block window for replaying a recently-played track.                                                                                                                                           |
| `RecencyDecayLambda`                           | `autoQ.recencyDecayLambda`                        | 0.1                           | Decay rate for reaction-recency weighting in sentiment (not the same knob as above — this ages *reactions*, not *plays*).                                                                                                     |
