Repository navigation
The music director and lineups #12
Replies: 2 comments
|
Status check, 2026-09-22 (main at 23b983e) Done: the daypart schedule (f350d50, Still open: a |
|
Closing, 2026-10-10 (main at be84ebc). Every entry here is built, moot, superseded, or moved. Built (most of these were already in the 2026-09-22 check):
Moot: the station's own permission surface. The point of a Superseded: plugins that programme the station (a Moved: palette steering. Distilling genres and year bands from enrichment to bias selection now lives in #207, § "Palette steering", beside the genre steer (#420) it would share a mechanism with. The note on the first break's placement ( |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Designed: 2026-08-07, while planning the music director
Revised: 2026-08-08. The first two entries have been built; the rest stand.
Status: deferred, not disputed. Each item has a known landing place.
Cut from the director plan to keep the first pass on one thing: getting a lineup on air. See
multi-station for the other deferred half.
Segments in a lineup — BUILT
A lineup line is a track or a segment, and the arm landed without a migration exactly as predicted,
because the items are jsonb (in
deadair.station_lineupnow; thelineupstable this entry waswritten against no longer exists). The rule landed with it: the director SKIPS a segment that is not
readywhen the cursor reaches it, so the station never stalls waiting on a renderer.Two things this entry did not anticipate, both of which turned out to matter. The line holds the
segment's ID and nothing else — no label, no state, no audio — because a copy inside a jsonb document
that is only rewritten on an edit would go stale the moment a segment was re-recorded. And a
talk-over is not a line at all: it never becomes an item, it rides on the record it is heard over.
A DJ that talks — HALF BUILT
The audio path exists end to end: a segment airs, the station plants its own, and it can talk over a
record with the bed ducked under it. What does not exist is anything that writes or speaks one —
every segment is a file somebody recorded.
The remaining half, and the seams it drops into, is dj-voice. The observation below
still holds and is why an LLM selector is cheap:
SetGenerator.generatetakes and returns namedpicks (title + artist strings), which is what a model can produce, so it is a second binding rather
than a reshape.
Live provider search when resolving a pick — BUILT
Built 2026-08-12, as
ProviderTrackLookupbehindPickResolver.identify's third rung, gated byrotation.discover(on by default) and bounded byMAX_DISCOVERIESper resolve. The entry was rightabout when it would matter — a model naming songs the library does not hold — and it landed alongside
the operator brief, which is what made the library's ceiling audible: a station asked for heavy metal
whose playlists are ambient has nothing to choose from.
Four things came out differently from this sketch, and the last two were not anticipated at all.
The scorer is stricter than "title/artist/duration". Both the title and the LEAD artist must
match exactly on
normalizeKey, which is the same normalizationdeadair.tracksis keyed by, so amatch here means what a catalog match means. Duration only breaks a tie between candidates that
already matched, taking the longer one so an album version beats a radio edit sharing its name. A
near-miss is refused, because the failure mode is not an error: it airs the wrong record while the
console says otherwise, and nobody watching would know.
The pick is INGESTED rather than resolved to a provider URL. The player fetches every record from
the app through
track_sources, so a copy with no binding has no URL and cannot air — the catalog rowis the mechanism rather than bookkeeping. It also puts the record in front of the measurement,
enrichment and art passes, which all walk the catalog, so a discovered record is trimmed and
illustrated by the existing schedules with nothing new to write.
The sync's missing sweep had to learn about it, and this is the part that would have been a silent
bug.
markMissingTrackSourcesmarks every binding a clean playlist walk did not see. A discoveredcopy is in no playlist and never will be, so the first hourly sync after a discovery would have
benched every record the station found for itself — a feature that worked for an hour and then
quietly stopped.
track_sources.origin(sync|discovered) is the fix: the sweep judges onlysyncrows, a walk that later sees a discovered copy moves it into the sweep, and a lookup nevermoves a synced one out. What judges a discovered copy instead is fetching it, through the existing
four-consecutive-failure bench in
TrackAudioService.Ordering against the rules is load-bearing. Ingest happens inside
identify, sojudgerunsafterwards and a newly ingested record inherits whatever the operator already thinks of its artist.
Without that ordering, a record by a disliked act would air because nothing had an opinion about it
yet — which is
rejectDislikedbeing routed around by a new path, the exact holestation-intelligence §1 records.
Changing the brief on a live broadcast
Written 2026-08-12, from using the brief for the first time.
station_lineup.briefis set whenthe station goes on air and there is no way to change it after that:
PUT /director/airsets theair mode and nothing else, so re-steering an hour means calling
putOnAiragain, which replaces therunning order. An operator who wants the next fifteen records to lean differently has to throw away
the fourteen they already have.
The shape it wants is the shape everything else here has: a command posted to
DirectorService(
{ kind: 'rebrief', brief }) that rewrites the binding and nothing else, aPATCH /director/air/orderin front of it, and an editable field where the header already draws
asked for: …. The order isuntouched — what is planned stays planned — and the next refill reads the new brief because
ExtendLineupJobreads it off the row every time rather than being handed it.Two decisions to make when it lands, neither obvious:
something else" and it is also an operator losing programming they may have wanted. Leaning
towards leaving it and letting the brief apply forwards, since dropping the tail is already
expressible one item at a time.
namefollows the brief. The console names a briefed broadcast after its brief, so are-brief leaves the header saying something the station is no longer doing. They are separate
columns for good reason and this is the one case where the split shows.
The daypart schedule
Morning / afternoon / evening / night, each naming a PLAYLIST rather than a stored lineup: there is
no stored lineup to name any more, and a changeover builds the running order from its slot's source
the way
putOnAirdoes. Seedocs/internals/director.md§ "Who owns the running order", which alsorecords why the schedule must post commands and bump the epoch synchronously rather than writing
anything itself.
The original entry, still true of the mechanism: The director already re-reads
deadair.station_airon every wake and switches when it names a different lineup, which is thebehaviour a scheduler needs. What is deferred is the
Programmeseam (current(): { slot, lineupId })and the changeover policy: finish the track, then swap.
Read back 2026-08-19, and three things above have moved. "Finish the track, then swap" is not a
policy still to be written:
Rundown.retract()reclaims everything handed and not heard and leavesthe airing record playing, and
putOnAircalls it first, so it is built. The seam isScheduleResolverrather thanProgramme(that word is spent twice over; see the naming note indocs/internals/director.md§ "Who owns the running order"), and itslineupIdhalf is stale,since there are no stored lineups to name.
And the sub-hour half of this is already built, which is worth knowing before designing it again.
deadair.clock_bandsanchors events inside the hour (half past for the news, nine o'clock for it, anident every ninety minutes), so
the schedule builder in todo splits: a bulletin at half past is a BREAK and exists today, and a
slot decides only the source, the host and the brief. A slot therefore stores a start minute rather
than painting a grid of hours, and the two mechanisms divide cleanly. Clock bands schedule what the
station MAKES; the daypart schedule decides what it PLAYS BETWEEN.
A slot naming a PERSONA as well as a playlist is personas §3, and costs the
scheduler one uuid on the row it is already building. What the station SAYS at a boundary that
changes the host is §5 of the same file, and it is the half with an ordering constraint in it: the
sign-off cannot ride the order
putOnAiris about to retract.The station's own permission surface
Routes use
platform.view/platform.managefor now. Deferred: astationnamespace incore.permwithview/request/program, so an invited listener can be granted the right torequest a track without being handed operator control.
station:requestdeliberately belongs to nodefault role; it is a per-actor tuple.
Plugins that programme the station
A
programmemanifest capability and ahost.stationsurface (nowPlaying,upcoming,request,insert,remove), gated by an operator-approved grant withrequest/insert/urgentflags.The breaking-news case: a plugin declares
streamtoo and its bulletin is fetched through theexisting
resolveStreamUrl, so no bytes cross the plugin boundary.position: 'now'is the onlypart needing new transport, namely
Rundown.insertNext, which retracts the served-but-not-airingtail so the bulletin airs next rather than five tracks later.
Corrected 2026-08-11: this entry used to say
station_air.resume_lineup_idandresume_cursor"already exist for handing the station back afterwards". They do not, and cannot:
station_airisdown to one column of substance (
active), because a second opinion about programming living nextdoor to the running order was the bug that on-air ownership closed. Handing the station back after a
bulletin is now what it always should have been — the bulletin is an item in the one running order,
and the records after it are still sitting behind it.
Push destinations
TuneIn AIR, a scrobbler, a Discord presence. Nothing pushes today; a consumer polls
GET /nowplayingand the mount carries ICY. When one is genuinely wanted it is a plugin declaring a
nowplayingcapability, fed by a
station.airedevent on@maroonedsoftware/eventbus(already a dependency,still unused).
Note the bus is synchronous and fail-fast, so the director's own top-up must stay on
Rundown.onAireddirectly and never behind it.Settled 2026-08-16, before anyone writes a subscriber: a push destination is a pg-boss job, not a
bus subscriber. The warning above is real and the right response to it is not to be careful. Every
property a push destination has is the property that makes something a job in this tree — it is
slow, it is outbound to a third party, nobody is waiting on it, and it wants retries and durability
across a restart. A synchronous fail-fast bus on the aired edge gives it none of those, and makes a
Discord outage cost the station its top-up unless every subscriber is defensively wrapped, which is
a discipline rather than a structure.
onAiredalready does exactly this correctly for playhistory: it is deliberately not a director command, it records what a listener actually heard, and
it hands its own work off to a job. A
nowplayingplugin capability is still right; what feeds itis
jobs.send, and@maroonedsoftware/eventbusstays unused. Seeapps/api/CLAUDE.md§ "How work is dispatched".Rotation rules as operator settings
Constants in code for now. Deferred:
director.*keys indeadair.settingsresolved the waystream.settings.tsdoes (repeat window, artist cooldown, per-artist cap, auto-extend, commit lead,source preference), with a lineup's own
rulesjsonb overriding them field by field. The schemacolumn exists already.
Palette steering
Distilling genres and year bands from enrichment to bias selection. The rotation rules land without
it; it is a weighting refinement, not a correctness one.
The station console page — BUILT
/onairdraws the live running order with each item's state, and offers Shuffle / Extend / Drop /Stop. Put on air lives on the playlists page, beside the thing the order is built from. What is not
built: moving an item (the API route and the table's
onMoveseam both exist), and adding a segmentfrom the console.
The first break of an order sits where the unscaled rule would have put it
Noticed 2026-08-28, while building persona chattiness, which is the first thing ever to scale
rules.breakEveryMinutes. It is a property ofplacementsForrather than of that feature, and it isrecorded here because the feature is what made it visible and nothing else would have.
relentlesson a four-track interval produces the identical placement array to an UNSCALED two-trackinterval —
[4, 7, 10, 13, …]on a twenty-record fixture, both times. So the multiplier is exact.What neither of them does is put the first break at index 2: the opening gap comes out where a
four-track interval would have put it, and only the gaps AFTER it are two records wide.
It costs one break at the top of an order and nothing afterwards, which is why this is a note and
not a defect. It is worth knowing for two reasons: anybody reading a placement array and expecting
the first index to be the interval will conclude the multiplier is broken when it is not, and anybody
who fixes it should expect every spacing assertion in
break.planner.test.tsto move by one.The mechanism was not chased down.
elapsedSinceLastOfKindreturns 0 at cursor 0, so the obviousexplanation — a seeded count — is wrong, and the honest state of this note is "measured, equal to the
unscaled equivalent, cause unexamined".
Moved from
docs/todo/director-and-lineups.md. The file as it last stood: https://github.com/robert-dean/deadair/blob/71e431d45ce7b35a189c7d226832ec4a6247e9dc/docs/todo/director-and-lineups.mdAll reactions