Repository navigation
Genre picks from providers without a model, and a palette steered from enrichment #207
robert-dean
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Status (2026-10-10)
This discussion asked whether a genre tag should be the period's second exception: a structured
steer beside the brief that a station with no model can still honour. Half of that has been
answered by building it, twice, and both halves lean over records the station already owns:
deadair.genre_steers(migration0069_block_rules.sql) holds one leanper station: a list of genres and an
ends_at.CatalogSetGeneratordraws a second, loose sampleof records tagged with something containing a steered genre, matches the draw precisely with
genre.match.ts, and gives the records really in a steered genreSTEER_LEAN(four) times theirweight (
director/genre.steer.ts). Nothing is ever removed.ModelSetGeneratoris told about thesteer too, as a lean. It is set on the Programme page's "Leans and rules" tab and through
/rules/steer. This is close to the "setting rather than a per-broadcast field" shape below, andneeded no model.
0066_slot_mood.sql), which became up to four stages through a slot(Mood arc: a slot's mood becomes up to four stages #528,
0084_slot_moods.sql). Its migration calls itself the period's second exception in as manywords: once a model has judged each record's mood, "lean toward comfort" is a number the floor can
multiply a draw by, with no model in the room.
So the first question this discussion said had to be settled ("is a second structured filter beside
the brief acceptable, or does migration 0017's argument still hold?") is settled: it is acceptable
when the floor can honour it alone, and only ever as a lean.
What is left is the part neither of those reaches: music the library does not hold. A steer
toward synthwave on a library with six synthwave records plays those six more often. Nothing
without a model can go and find a seventh. That is the remaining question, plus the model-emitted
version of the same path, plus a second idea folded in from
director-and-lineups: a palette steered automatically from enrichment
rather than set by an operator.
The gap that is left
Written: 2026-09-20, after #205 added Deezer and ListenBrainz as similarity sources. Every
endpoint claim below was checked against the live services on that date, and every claim about the
tree against the code rather than the docs. Rechecked against the tree on 2026-10-10.
SetGeneratorChainregistersModelSetGenerator,ChartSetGenerator,SimilarSetGenerator,CatalogSetGenerator, in that order (director.module.ts:138-143). What each does with the briefand with a steer:
ModelSetGeneratorsearch_musicChartSetGeneratorchooseChart); never filters oneSimilarSetGeneratorCatalogSetGeneratorignoresBrief = true)So the only path from a GENRE to a record the station does not own is a model. Even that path is
narrower than it looks:
search_musictells the model in its own description(
modules/llm/music.search.tool.ts) that it "matches NAMES and styles in the library, but only NAMESat the providers", and that "at the providers a style like 'rap' finds records with that word in the
title, not records of that style". A model filling a synthwave hour has to name synthwave artists
from memory and search for them one at a time.
What is available without a model, measured
GET /1/lb-radio/tagson ListenBrainz: recordings carrying a MusicBrainz tag, with AND/OR acrossseveral tags and a popularity window. No token. Checked 2026-09-20:
Six rows of
{ recording_mbid, percent, tag_count, source }. Titles need a second, also keyless,POST /1/metadata/recording/, whoseartist.artists[0]is the LEAD artist the pick path needs.What came back for
synthwave: Galatée, Mitch Murder, From Fiction, Shuriken Sun, MentalMinority, Mlada Fronta. Tag-accurate, every one of them.
The ListenBrainz client already exists:
plugins/musicbrainz/src/listenbrainz.client.tscalls/1/lb-radio/artist/{mbid}for similarity. The tags endpoint is a sibling of one it already reads.Two limits, both real:
pop_begin=40the same query answered a Deus Exsoundtrack cue and something called "Turbine Tunnel Bass Arp II 125BPM". The popularity window
is a dial, not a fix. This is the same property that made the radio endpoint unusable for
artistTopTracksin Two more similarity sources, and an order for them #205, where ranking by its own listen count still produced a Daft Punkmedley and a mashup.
synthwaveworks. "Flamenco guitar with a bit ofswing" is not a tag, and never will be. This can answer a genre steer and cannot answer a brief.
Deezer cannot do this job. Its genre list is about twenty broad buckets (Pop, Rock, Electro),
and
/genre/106/artistsanswers Drake, Bad Bunny and Taylor Swift. Its free-text search matchestitles literally:
q=synthwavereturns songs CALLED Synthwave. Checked the same day. So thekeyless tag path is ListenBrainz's alone, which also means it inherits ListenBrainz's habit of
closing endpoints to anonymous callers:
/1/metadata/lookup/and/1/popularity/top-recordings-for-artist/already answer 401 with a sentence about scrapers.The design question
A tag is the period's argument a second time. The period earned a column because
CandidatesRepository.samplecan narrow on it, "so a station asked for a decade keeps playing onewith
llm.setGeneratoroff entirely, which prose can never do". A genre tag buys exactly that,for exactly that reason, and pays exactly the same price: it is weaker than the sentence an
operator would rather have written.
The difference worth arguing about is where the records come from. A period narrows the DRAW over
records the station already owns. A tag would have to reach a provider, because the whole point is
music the library does not hold, so it is a new generator rather than a column the floor reads.
The genre steer has since made this sharper: the input already exists (
genre_steers.genres),and what is missing is only the generator that takes it outside the library.
Shapes, revised now the steer exists
The three shapes this discussion first offered were a
tagscolumn beside the period, astation-wide setting, and model-emitted tags. The first two have been overtaken: the steer IS the
station-wide input, and it is stored in a table with an expiry rather than as a setting. What
remains:
A
TagSetGeneratorthat reads the steer. While a steer is in force, ask/1/lb-radio/tagsforits genres, resolve the rows to titles and lead artists, and hand them to
PickResolveras namedpicks, which ingests what it can find at a provider exactly as it does for the model's picks.
Registered between the chart and similarity bindings, so a station with no model and a steer in
force gets records it does not own, and a station with neither falls through unchanged. The steer
already says "lean, never only", so this generator should supply SOME of a refill and leave the rest
to the generators below it, the way
similarMixdoes, rather than owning the batch.Let the model emit tags.
set.prompt.tsalready tells the model to passyearFrom/yearTorather than writing "80s" into a query.
search_musiccould taketagsthe same way, answered atthe providers through the same lookup, so a model asked for synthwave gets synthwave rather than
songs called Synthwave. One path would then serve BOTH the model-less station (operator-set steer)
and the briefed one (model-chosen tags). This is still the most interesting version and the one
that makes the feature earn its place twice.
Per-broadcast genres (a
tagscolumn onschedule_slotsandstation_lineup) stay possibleand stay unasked-for: the slot's mood showed the column route works, and the steer showed a
station-wide lean is enough to start with.
Against building it at all
The station this was written on (
radio.deanhome.app) runsllm.setGeneratoron,llm.alwaysSearchProviderson,briefOnlyon andsimilarMixat 0.6. For a station configuredlike that, guidance already works and this adds nothing beyond better provider matching for the
model: the model reads the brief, searches the provider on every call, and
PickResolveringestswhat it names. The gap is real only for an operator who does not run a model, and the honest
question is whether that operator exists in numbers worth a generator.
The counterargument is cost rather than capability: a tag lookup is two cheap requests where a
briefed refill is a model conversation with tool steps, and a station that wants a genre hour is
paying for a language model to rediscover the word "synthwave" every time.
What would need deciding first
Is a second structured filter beside the brief acceptable?Settled by Moods: the model judges each record, and a slot can lean toward one #413 and Never-play rules, and a genre lean #420; see thestatus above.
Per-broadcast (a migration) or station-wide (a setting)?Station-wide first, through the steerthat already exists.
so a free-text field that silently matches nothing is the obvious failure.
/1/lb-radio/tagsanswering empty for a tag nobody uses is the same shape as the advisory
clean-onlyproblem: thehonest answer is "nothing to play", and it has to be SAID rather than looking like an outage. The
steer's genres today are matched against the station's OWN tags (
genre.match.ts), which are notpromised to be MusicBrainz's spelling.
station that asked for a genre and expected to recognise something.
fail open into the generators below it, like every other source.
Palette steering: a lean the station works out for itself
Folded in from director-and-lineups on 2026-10-10, where it was the
last open entry. As it stood there:
And from from-v1, the previous station's version of it: durable play
history driving a repeat window and artist cooldown, per-artist caps, and a palette derived from
what actually aired rather than from the pool.
Why it lives here now: it is the same draw and the same kind of weight as the steer above, and
the difference is only who chooses the genres. The steer is an operator saying "synthwave until
ten". A palette is the station noticing that the last hour was mostly synthwave and late-80s, and
leaning the next refill that way (or away from it) without anybody saying so.
What exists on 2026-10-10, and what none of it does.
CatalogSetGeneratormultiplies in severalleans (
catalog.set.generator.ts, thescoredmap): the mood (mood.lean.ts), the genre steer, anartist coming back (#525), a deep cut (#525), and a skip (#527,
skip.lean.ts), plus the smartshuffle's freshness. Every one of them is either set by an operator or read from history about a
RECORD or an ARTIST. None distils a genre or a year band from what aired. The generator's own header
still says so: "There is no similarity graph, no palette, no sense of flow beyond keeping one artist
off its own heels."
The data it would read is now in the tree.
CandidatesRepository.tagsForanswers each track'sgenres from the enrichment arrays at the track and at its artist (never the promoted
tracks.genrescalar), and
genre.match.tsalready says when one tag refines another. A year is on every track.play_historysays what aired. So a palette is a query over the last hour'splay_historythroughtagsFor, a distribution of genres and decades, and a weight, which is the steer's own mechanismwith a computed input.
What would need deciding first:
is variety. The rotation rules already push away from the same ARTIST and SONG; whether genre
should be pushed the same way, or pulled, is the whole design.
measurement of what aired. A palette has to be relative to the library's own distribution, or it
is the rotation pool's shape fed back to itself.
steer should win where they disagree, and the obvious place to say so is that a palette is not
computed while a steer is in force.
refill at most, and a failed read should cost the lean and never the batch, like every lean
beside it.
Written 2026-09-20 against main after #205. Rewritten 2026-10-10 around what is still open after
#413, #420 and #528, with #12's palette steering folded in; the earlier text is in this discussion's
edit history.
All reactions