Quill Radio – Product Requirements

Version 3.0.0

1. Product statement

Quill Radio is QUILL’s internet radio, shipped as its own small Windows app for people who want the radio on without loading a full writing environment. It is screen-reader-first, keyboard-complete, and deliberately small – and by 1.0 it is a complete radio product: organization, recording resilience, timers, and appliance-grade startup.

2. Architecture requirement: not a fork

3. Scope (all reused from upstream; all shipped in 1.0)

Listening

Organization

Recording

Timers

Shell

Out of scope by decision: QUILL’s editor, AI, speech transcription, braille, and TTS stacks (not installed); a custom update engine beyond download-and-run.

4. Accessibility requirements

5. Packaging requirements

6. Network requirements

6a. Accessibility rules every browse branch inherits

Standing requirements, not a description of finished work: a new source in the Browse Stations tree meets all of these or it does not ship. They were learned one at a time from real faults, which is why each says what goes wrong rather than only what to do.

Most of these the dialog already honours. They are written down so the nine new providers inherit them rather than each rediscovering them.


7. Non-goals

macOS/Linux standalone builds (upstream QUILL covers macOS; the tray pattern does not exist there), auto-updating in place, telemetry. A full DSP effects rack (reverb, tempo/pitch, spatial audio) – Sound Enhancements (§8) is a small, purpose-built three-band EQ and compressor, not a general effects rack.

YouTube search and browse (decided 2026-08-12). yt-dlp’s ytsearch: works, needs no key, and is against YouTube’s Terms of Service. The sanctioned route is the Data API v3, which needs a key and carries a quota. Radio’s other directories (RadioBrowser, SomaFM) are open APIs built to be consumed this way; YouTube is not, and the difference is the whole argument. No search branch – paste a link or a playlist. Revisit only via the Data API with the listener’s own key, which is the shape the Spotify integration already uses.

Commercial and relationship-gated directories (closed 2026-08-14). Quill Radio will not pursue, license, or integrate a station directory that requires a commercial agreement, a paid tier, a partner approval, or a per-application developer key. That closes the whole pile that had been kept open as a “worth an email” list: airable, vTuner, myTuner, Radioplayer, Radio.net, Online Radio Box, Streema, and the SHOUTcast developer directory. Broadcastify was already out on its own terms (as of June 2026 the feed-catalog API is USD 2,500 per month and they decline to license new consumer-facing scanner applications).

Three reasons, and the third is the one that settles it:

  1. N-3 and N-4. A recurring cost is forbidden outright, and even a free key is a key to configure, to support, and to explain to somebody at the worst possible moment. That is the same reasoning that put Podcast Index out.
  2. The premise expired. vTuner’s selling point was its Location / Genre / Language / Quality classification – and Quill Radio 3.0 builds Country, State, Language, Genre, Quality, Trending and Recently-Changed browse from data it already fetches, for nothing. A commercial catalogue moved from “the thing that unlocks browse” to “a second opinion on a catalogue we can already browse”, which is a very weak reason to accept anybody’s terms.
  3. It is a standing dependency on somebody else’s goodwill. Every branch in the tree today can be switched off and the app is whole; none of them can be revoked. A licensed catalogue can be, and building the discovery experience of an accessibility product on something revocable is the wrong trade at any price.

The SHOUTcast API request sent on 2026-08-12 is abandoned rather than awaited, and the two commitments made in it (a spoken source label, and per-source recording suppression on request) lapse with it – nothing was ever built on them. SHOUTcast streams remain fully supported; it is only the directory that is out, and My Servers browses an individual SHOUTcast or Icecast server with no directory involved at all. Radio Garden stays out as a directory for the same reason (undocumented API, no terms); its appeal is largely covered by the Wikidata axes and Browse by Country.

Reopening any of this needs a new reason, not a new email.

A watch link carrying &list= is not a playlist. The listener asked for that video. Quietly expanding it into fifty stations would be a nasty surprise, and “it was technically in the URL” is not consent. Add-from-playlist is its own explicit command.

8. Since 1.0

See CHANGELOG.md for the full, versioned history.

9. Weather – now its own product

The weather feature set (Weather Center, Weather Guardian alert monitoring, the alert model, voice/speech plans, and the roadmap) has its own home now that Quill Weather is a standalone app: see ../../weather/docs/prd.md.

Weather remains available inside Quill Radio via the Weather menu when the Weather feature is enabled (View > Customize Features…), and that menu offers Open the Quill Weather App to hand off to the standalone watcher. The two are separate, independently-distributed apps that run side by side.

10. Spotify integration (experimental)

Quill Radio (music) and QUILL Cast (podcasts) can play directly from Spotify. The capability is experimental and ships in the app. It is gated behind the future.spotify feature flag in quill/core/feature_catalog.py – no longer locked_off, and no longer behind an unlock code, since that mechanism was withdrawn. The flag remains so a listener who does not want Spotify can switch it off in Manage Individual Features and have its menu items disappear. Nothing reaches Spotify’s servers until an account is deliberately connected, behind a one-time network-access consent, and the whole feature is refused in Safe Mode. This section is the design of record and the honest statement of what is left before general availability. As with every radio feature (R-1), all of it lives in the shared quill package – quill/core/spotify/* and quill/ui/spotify/* – and nothing is vendored into this wrapper.

10.1 Design

10.2 Enablement checklist

Playing from Spotify requires all three of the following. Missing any one means playback never starts.

Requirement Detail
Spotify Premium account The Web Playback SDK only streams audio for Premium; the requested scopes (streaming, user-read-email, user-read-private) are Premium-only. A free account can browse but not play.
User-supplied Spotify Client ID Registered at the Spotify Developer Dashboard, with the redirect URI set to exactly http://127.0.0.1:43217/callback. No client secret – PKCE.
Windows with the Edge WebView2 runtime The only sanctioned playback path is the WebView-hosted SDK; QUILL already warms WebView2 at startup.

Whenever the feature is on and the app is out of Safe Mode, the standalone frames (quill/apps/radio.py, quill/apps/podcasts.py) add Connect to Spotify… and Browse Spotify… (Radio) / Browse Spotify Podcasts… (Cast) to the Help menu; the accessible dialogs are quill/ui/spotify/connect_dialog.py and quill/ui/spotify/browse_dialog.py.

10.3 What remains before general availability

The offline-testable core and the wx state machine are done and unit-tested, but the following cannot be exercised without a real Premium account and Client ID and are explicitly open:

11. The Station Catalog - PRD and strategic plan (merged)

Formerly docs/station-catalog.md (and before that the repo-root ideas.md); merged here 2026-08-15 so the radio PRD is one document. Internal section numbers (2b, 5.5, 6.5…) are preserved because code comments, changelogs and tests cite them.

Status: plan of record, and as of 2026-08-15 largely built - Phases 1-3 shipped together as the station-catalog feature branch (see the Delivery log at the end). Owner: Jeff. This document graduated here from a root working note the same day.

One sentence: Quill Radio ships the station directories inside the app as a fast local catalog, keeps that catalog current from live data automatically and on demand, lets the listener turn every automatic behavior off, and never - under any circumstance - touches a station the listener added or saved.


1. Why this, and why now

Browsing today is lazy: every branch of Browse Stations fetches from its source when expanded, softened by directory_cache (fresh -> live -> stale). That design shipped 3.0.0 well, but it has three costs the listener feels:

The fix is not more caching of the same shape. It is inverting the default: the local catalog is the source of truth for browsing; the network is the source of truth for freshness. The app reads locally in milliseconds and reconciles with live data in the background - a shape this codebase already proved twice, in miniature: the NOAA wxindex bundled snapshot with a refresh tier, and the Radio Reading Services directory with its manual update command. This plan makes that the architecture for every source that permits it.

2. What exists today (grounded inventory)

Read before designing anything; all of this is on main.

2b. Phase 0 findings - measured, 2026-08-15

Phase 0 ran early: the real exports were pulled once and the store was prototyped against them (local/catalog-proto/). Numbers below are from this machine; every design amendment they forced is applied in the sections that follow.

The data

Apple Podcasts (iTunes) - why it is not measured for seeding

Asked and answered rather than silently skipped. The Podcasts (Apple) branch rides the iTunes/Apple Marketing endpoints (apple_podcasts.py: genre tree, per-storefront charts, show lookup), and it is deliberately not a seeding candidate, for three reasons that stack:

So Apple stays exactly where Section 4 puts it: Class B, live-drill, with the existing honest empty-state when unreachable.

The complete source sweep - every source, measured 2026-08-15

Every source in the app was exercised live, not just the seeding candidates. Counts are what one fetch returns through our own modules unless noted.

Source Measured Bulk feasible? Class Note
Radio Browser 62,377 stations, 77.7 MB raw Yes (paginated) A The seed’s backbone
SomaFM 46 channels, 51 KB Yes A Trivial
Xiph 0 today; ~500 genres on Aug 13-14 Yes when up A Flapping - ruling below
NOAA wxindex 40 states with feeds Already bundled A Existing snapshot joins the store
Community M3U 95 genres (~1,900 stations in first 5) Already bundled A
Networks / ACB / NFB / Reading 6 groups / 10 / 1 / 21 Already bundled A Curated, highest trust
iHeart 18 genres, 317 markets Barred by terms B Section 2b terms reading
TuneIn 7 root categories Barred by policy B Remote drill tree
Apple Podcasts 19 genres in 0.8 s; 100-show chart in 1.7 s Barred by terms; rankings anyway B
Internet Archive oldtimeradio 8,853; librivoxaudio 21,747; 78rpm 310,899; netlabels 80,423; audio_music 522,283 items No - millions of items B Numbers close the question
LibriVox ~22-24k books total; limit=1000 paging works (1,000 books = 1.31 MB, 1.9 s; API 404s past the end) Yes (~30 MB raw, est. 3-4 MB compressed) B today, A2 candidate Public-domain data; see below
Project Gutenberg audio 1,124 records total (gutendex count, audio/mpeg) Yes (one fetch) B today, A2 candidate Tiny
Wikidata axes 400 stations per axis in 2.1 s Yes (CC0) B Small derived joins; cache suffices
Audius / Mixcloud / ccMixter 59 trending / 38 categories / 16 tags Rankings and charts B Stale by definition if seeded

Class A2 - the library seed - is ADOPTED (2026-08-15), not parked. The directive is to cache everything legally allowed, and LibriVox (~22-24k public-domain books, cleanly pageable) and Gutenberg audio (1,124 records) qualify without a caveat: finite, freely redistributable, together an estimated 3-4 MB compressed, inside the seed budget’s reserve. They ship in the seed as their own tables (books and sections are not stations; forcing them into the stations schema would bend both), land in Phase 3 right after the station machinery proves itself, and make the two audiobook branches browsable offline and instant. Wikidata’s axis joins (CC0, 400 rows per axis) also become a seeded enrichment table rather than a live cache. Internet Archive stays live-drill permanently - the measured half-million-item collections end that discussion - and Apple, iHeart, and TuneIn stay out on terms, which is the directive’s other half.

The Xiph outage, diagnosed (2026-08-15)

Troubleshot rather than shrugged at. Findings, in order:

Consequences, all already in this plan: the empty-answer-is-an-outage rule (5.6) exists precisely for this; the directory_cache stale tier would have bridged it in the app had the local caches not been cleared today; and under the catalog design this whole class of outage becomes invisible - the listener browses the last-known-good Xiph data while the source is marked stale with its age. Worth doing besides: report the flapping backend upstream to Xiph, since the site presents no status page.

Ruling on “pull it out of the package” (2026-08-15): Xiph is not unrecoverable - it served ~500 genres to this codebase two days ago - so it stays in the code, and the tripwire has been EXECUTED rather than deferred: default_on=False shipped the same day (PR #1401). New profiles do not see the branch; anyone who enables it in Choose Browse Sources keeps it; the honest empty-state still explains the outage for those who look; reversal is one line when the backend holds steady. Removal outright happens only if the source stays dead for sixty days (from 2026-08-15), at which point it exits ROOT_SOURCES, the seed builder, and the docs together.

The store (built from the real dump)

The read path, end to end

Catalog rows were materialized through the real BrowseNode/RadioStation types the tree consumes:

Windows swap semantics (the trap the prototype caught)

os.replace over a database an open SQLite connection holds fails with PermissionError on Windows - so the first draft’s “stage then swap the .db” design cannot work while any reader exists. Verified fix, also prototyped: generation files plus a pointer. Refresh writes catalog.<n>.db, then atomically replaces a tiny CURRENT pointer file that readers consult on open; old generations are deleted once unreferenced (next launch at the latest). The pointer replace succeeded with the old generation still open. Section 5.1 is amended accordingly.

SHOUTcast, Live365 and Radio Paradise (added 2026-08-26)

Three sources shipped out of the StreamTuner review (radio2.md), which read StreamTuner-ng and StreamTuner2 and then verified every endpoint against the live services before a line was written. What follows is the part a future maintainer needs: what each one is, why it is reachable without a key, and where each will break.

SHOUTcast (core/radio/shoutcast.py, browse ids shoutcast, shoutcast:<genre>, shoutcasttop). The Winamp yellow-pages API was retired after the 2014 Radionomy acquisition and no key has been obtainable by an open-source desktop app since; what answers instead is the directory website’s own form-POST endpoints – Home/BrowseByGenre, Home/Top, Search/UpdateSearch, plus a GET of the genre index – which both open-source StreamTuner generations have used across a decade and two changes of ownership. Measured facts that shape the code, none of which either client gets right: a genre reply is capped at 500 rows; Listeners is sparse on a genre page (39 of 500 in a live Jazz reply) and universal on Top; StreamUrl is always null and IceUrl nearly always empty, so the public tune-in .pls is the only route to audio; names collide while ids do not; and the reply is UTF-8 that a locale-default decode refuses outright. An honest User-Agent works – tested against a spoofed browser one, byte-identical result – so this source does not breach the descriptive-UA rule every other directory here follows. There is deliberately no “all stations” branch: the directory has no such endpoint, and synthesising one means 313 requests behind one tree node.

Live365 (core/radio/live365.py, ids live365, live365:<letter>). The auth-gated station API is still not used, and that ruling stands. What changed is that Live365 publishes a public sitemap its own robots.txt advertises, listing every station page as /station/<Name-Slug>-a<id> – and the a##### id in that path is exactly what this module’s existing pure transform turns into a stream URL. One 923 KB GET, 5,493 stations, cached a day as (slug, id) pairs rather than as XML or as built rows. This is the same posture as the iHeart source, which reads iHeart’s sitemap for the same reason. No metadata comes with it – genre and bitrate live in each stream’s ICY headers, one request per station, so they must stay lazy and must never be fetched for a whole letter.

Radio Paradise (core/radio/radio_paradise.py, id radioparadise). api.radioparadise.com/api/list_chan?list_type=json is keyless and 8 KB. The stream addresses are not in it (its stream.stream_url is always empty), so the naming pattern was verified channel by channel against the live server: the Main Mix uses codec names (aac-320, mp3-192, flacm), everything else uses <slug>-<quality> with -flacm for lossless, and Serenity breaks the pattern twice (bare slug for 64k, -flac for lossless). Radio 2050 is a real channel the API omits and is added explicitly. Each channel becomes one row per quality, which is the point: the bitrate is the listener’s choice, and a name-search integration can never offer it. This replaces the networks.py entry, which was a Radio Browser name query.

Class, for the seeding plan above: all three are B. SHOUTcast lists live audience figures that are stale the moment they are stored; Radio Paradise is seven channels, cheaper to fetch than to seed. Live365 is the only arguable A candidate – 5,493 rows of stable id-to-name mapping – and it is already cached for a day locally, so a catalogue seed would buy the first launch and little else.

Where each will break, in order of likelihood. SHOUTcast’s undocumented endpoints change shape (tolerant parsers, typed errors, and a source-health count that now says so out loud); Live365 reorganises its sitemap paths (one regex, and the branch empties rather than misbehaving); Radio Paradise renames a stream (one row per channel stops playing, the rest are unaffected). None of the three can take the window with it: browse() catches, browse_failure distinguishes empty from broken, and source_health counts.

Per-source health (added 2026-08-26)

core/radio/source_health.py records, per browse source and per session, the last outcome and the consecutive-failure count. It exists because browse_failure deliberately answers a question about one call – was this folder empty or broken – and nothing answered the question about a source over time, so three twelve-second timeouts read as three unrelated hiccups.

Two decisions worth keeping. It is in-process only: nothing is written to settings, a restart starts clean, and the module cannot corrupt or migrate anything. And it never disables a source – StreamTuner-ng trips a plugin off after three strikes; here the count changes what is said (the extra clause on an empty branch, via browse_sources.repeat_failure_note) and never what is done, because a source that vanishes from the tree is a worse failure than the outage it was hiding, and directories recover.

“Can we not load it all into a database?” – the answer, 2026-08-26

Asked while the search work was going on, and it deserves a straight answer because the answer is that we already do, for the half where it is legal and possible.

quill/core/radio/catalog/ is a real local database – SQLite, generation- numbered behind a pointer file, seeded from a snapshot and refreshed on a schedule the listener controls (catalog_enabled, catalog_refresh_on_startup, catalog_refresh_hours). federated_browse._stations asks it first and only falls through to the live directory when there is no catalog: that is why the station half of a cross-source search is already instant and works offline.

What is not in it, and why, is the whole of the answer:

So the useful version of the idea is narrower than “load everything”: warm the caches in the background before the first search needs them, which today happens lazily on first use. That is a real improvement, it is bounded (two requests), and it belongs behind the same consent and Safe Mode rules as every other startup fetch – so it is written down here rather than slipped in.

Search speed, and the shape that was actually slow (2026-08-26)

The first pass at “search is slow” fixed the pool (six workers over sixteen targets = three waves) and added a deadline. It was still slow, and the reason was not the fan-out at all: TuneIn and iHeart each resolve their results one network round trip at a time, ten and five deep respectively, inside the target’s own thread. A fan-out cannot help with work that is serialised inside one branch of it.

Both now resolve through a shared directory_search._in_parallel helper – one helper rather than two copies, because the two call sites had the identical fault and a fix written twice diverges. The SHOUTcast search route in the tree passes resolve=False: its rows are lazy and resolve on play, so a tree search there is one request rather than twelve. With the serial shapes gone the deadline came down from 12 s to 8 s.

The lesson worth keeping: measure where the seconds are before parallelising the thing that looks parallel. The fan-out was already concurrent and was never the problem.

The Station menu on every surface (2026-08-26)

The access-key gate below fixed menus that could not open; this fixes the menu that was not there. Each peer window’s bar carried its own menu plus &Window and nothing else, so Alt+S – the Station menu, in the main window – opened nothing in Browse Stations, which is exactly where somebody lives while using this app. Asked twice in one day, which is the signal that it is a model problem rather than a missing item.

quill/ui/radio/surface_app_menu.py is the whole mechanism: a table of (label, keymap id, fallback key, host method) and a loop. install() appends a &Station menu bound to the app shell’s own methods – there is no second implementation of any command – and host_of() resolves the shell whatever a given surface called it (_host, _transport_host, _download_host, _app_host; four names, one object, and the resolver keeps a fifth from being invented). Each surface passes a skip naming the commands it must not offer: itself, and anything whose key it claims (Browse’s Ctrl+F is its Find box). Three dialogs had no reference to the shell at all and gained an app_host kwarg. A host without the radio commands – Cast shares two of these surfaces – gets no menu rather than a broken one, and tests/unit/ui/test_surface_app_menu.py pins the list of surfaces so the next peer window cannot ship without it.

Search, fourth pass: remembered answers and a warm start (2026-08-26)

Passes one to three fixed the fan-out, the per-result resolution, and the first-render latency. What remained: the first search of a run paid to fill the local caches it was supposed to be answered from, and a repeated search paid the whole fan-out to learn mostly what it already knew.

Source-declared options (2026-08-26)

radio2.md part VII, implemented. quill/core/radio/source_options.py holds the declarations – ChoiceOption and SecretOption, deliberately nothing else – and two consumers: Radio Paradise’s preferred quality (reorders which row Enter lands on; hides nothing) and SHOUTcast’s live-only filter (default everything, because “the directory lists it” is the honest default and a filter that hides 460 rows by surprise is worse than a sorted list). Values live in one dict on RadioHistory, cleaned on load and save; the in-force copy sits in the module (set_current/chosen) because the wx-free source clients are called through a browse contract that has no route to the history record. Rendering is quill/ui/radio/source_options_menu.py – the platform’s own dialogs, extracted on arrival rather than grown into the menu module. The SecretOption shape exists ahead of the first keyed source so the credential-never-in-a-row rule (part VI) has somewhere to hang.

Asked for as “all top level menu items on the menu bar need rich ways to invoke them”. The item-level rule (every enabled menu item shows a key, no two share one) has been enforced since 3.0; the titles were unchecked, and the check found two live faults in one pass: Quill Radio’s &Quillins collided with &QuillVille (both Alt+Q) and Audio Studio’s &Voices with &View (both Alt+V). In each pair one menu simply never opened.

tests/unit/ui/test_menu_bar_access_keys.py reads the literals out of every menu_bar.Append/Insert in the tree and asserts both properties. It also pins the sheet’s mnemonic reader, which takes the letter from the label’s own & rather than the first character – Vi&deo is Alt+D, and a sheet that guessed would be confidently wrong.

The discovery half is the Keyboard Shortcuts Sheet: it now leads with a Menus group built from the bar in front of the listener, so “what opens the menus here?” is answerable in the window where it is asked. That matters more in this app than in most, because every window is a peer carrying its own single menu.

SHOUTcast rows are resolved on play (2026-08-26)

Reported within the hour: “many of the stations from the SHOUTcast directory are not playing”, with a worked example carrying 293 live listeners. The cause is in the shape of the source rather than in any station: tunein-station.pls?id=N is a playlist, and a player is handed a stream. Every other source here already knew that – soma_fm resolves its own .pls for the same reason.

The fix follows TuneIn’s pattern exactly: browse rows are lazy_leafs carrying shoutcaststation:<id>, and browse_sources.resolve() turns one into a real address when it is activated. One request on the station somebody chose beats five hundred for a page they are reading. Search is the exception and has to be: Find Stations hands a row straight to a player and cannot resolve it later, so shoutcast.resolve_many resolves the top 25 concurrently and drops what will not resolve rather than offering a row that fails on Enter.

Two side effects worth recording. playlist_formats.parse_pls finally has a runtime caller – that module was a tested toolkit nothing imported. And browse_sources.py passed the ~1000-line decomposition point its own budget entry had named, so the handlers were extracted to core/radio/browse_directories.py rather than the budget being raised a fourth time; the budget was ratcheted down to 950 in the same change.

Television (2026-08-27)

The iptv.org community catalog, joined from its six published JSON files (core/radio/iptv.py, browse ids tv, tvcountry, tvcategory, tvarea). Measured before designed: 40,815 channels, 16,941 streams, of which ~9,300 survive the four recorded cuts (NSFW flag – family audience; closed; no stream; streams demanding a disguised Referer/User-Agent, which the honest player would fail). Streams carry qualities; the best per channel wins and the row says which. feeds.json broadcast areas (c/US, s/US-MO, ct/USNYC) plus subdivisions.json/cities.json give the local axis: a city channel rolls up to its state, so Nationwide / state / city is real structure, not name-parsing. A five-digit ZIP maps to its state through a bundled postal prefix table – approximate at edges, labelled as narrowing, never as reception prediction. AntennaWeb is a browser link-out by ruling: no published API, and scraping an undocumented commercial SPA is what the egress policy refuses (the Live365-API precedent). XMLTV is a local file by ruling (core/radio/xmltv.py, safe_xml, 64 MB cap, mtime-cached parse): there is no one guide feed to fetch, and a file the listener placed is consent in its plainest form – zero egress. Cache: seven days (the heaviest catalog in the app, ~28 MB) with an explicit “Update the channel list now” action; the warm-up may pre-fetch it once per run. One reviewed egress entry covers all six files.

Quillin browse sources – radio2.md part VIII, shipped (2026-08-27)

The last part of the StreamTuner review, and the platform already had its hard half: Quillin fetch is SSRF-hardened and bounded by each manifest’s net_allowed_hosts – the “declared and bounded egress” the review asked for and StreamTuner has no answer to. What shipped is the contribution shape: directory_providers entries may declare a browse triocategories_handler, stations_handler, resolve_handler – validated with the cross-field rule (categories/resolve require stations) said at manifest time, where an author reads it. The app host registers the trio through the same _invoke_result_handler mechanism every host-mediated provider uses; directory_registry grew the browse half; the tree gained a Quillin Sources root that visible_roots drops when nothing is registered (an always-present empty branch is a shelf with a label and no shop). Every contributed row passes one validator (station_from_row); a row may carry a key instead of a URL and resolve at play time – the tokenized-locator rule from part VI, now load-bearing – and a keyed row with no resolver is dropped rather than offered and failed. The bundled Radio Community Directory sample demonstrates all of it, including the resolve step.

The keyboard sweep (2026-08-27)

Three classes, each with a gate so it stays fixed:

  1. Mnemonic shadows – the Alt+S root cause. A control label’s mnemonic outranks the menu bar’s on Windows, so "&Stations ..." beside the browse tree disarmed the &Station menu in its own window; seventeen labels across seven bar-carrying windows were doing this (Recordings’ list label ate its own &Recordings menu). All moved to free letters (the tree is Alt+T); test_mnemonic_shadows.py maps each surface to its bar letters and fails on any theft. Modal dialogs are exempt on purpose: with no menu bar, control mnemonics are the correct pattern.
  2. Keyless menu items, family-wide – the radio-only gate turned out to be the only gate. A source-level sweep of every app bar found 116 items with no accelerator (Cast 46, Studio 30, Player 18, Inkwell 10, Weather 10, Converter 6). All keyed, unique per app, collision-checked against each app’s literals and Cast’s keymap; test_app_menu_accelerators.py decides bar membership by AST (a menu is a bar menu if it reaches menu_bar.Append or hangs under one), exempts popups/tray menus mechanically, and exempts disabled status readouts the same way the radio gate does.
  3. Bar-title mnemonics – the earlier gate (test_menu_bar_access_keys) continues to hold every bar title to a unique Alt letter.

iHeart search, by iHeart (2026-08-27)

Search now asks api/v3/search/all (relevance-ranked ids, keywords only) and batch-fetches those stations with streams embedded – two GETs, replacing two sitemap GETs + local substring filter + one page fetch per match. Better answers (“kiss” → Kiss 108, KIIS FM), faster, and the more compliant shape: the terms reading that bars bulk-copying the directory is exactly why a search should ask the service. The sitemap path remains for what it was built for.

The five reports from running it, same day (2026-08-26)

Jeff ran the new sources out of the source tree the hour they landed. Every one of these is a fault the tests could not have found, and four of the five are older than the change that revealed them.

A new source never reaches an existing profile. normalize drops any id a stored selection does not name, so a source added later is invisible to everybody who has ever opened the chooser. Browse sources learned this on 2026-08-23 (the Podcast Index branch) and grew INTRODUCED_BY_EPOCH + SOURCES_EPOCH; the fix here is one entry in that table (epoch 2) and the same machinery added to search_sources, which had the identical hole and had simply never had a source added since. Rule for every future source: an entry in the epoch table in the same change, or it ships to nobody.

Two surfaces, one setting, no signal. Choose Browse Sources wrote the setting; the open tree kept the roots it was constructed with. Fixed with BrowseTreeDialog.apply_visible_sources – a public method the shell calls after saving, which also lets it hold a stale reference safely. The general lesson, which applies to every remembered choice with a live surface: writing the setting is half the change.

Sorting names. ACB Media 1, 10, 2 is what text sorting does. The refused fix was zero-padding the names; the taken fix is quill/core/radio/natural_order.py, one key used by every list of names a human reads. The display name belongs to the broadcaster, the ordering belongs to us.

One SHOUTcast root, not two. The live Top 500 shipped as its own root branch beside the genre browse. Asked to merge them, and the merge is right: it is the most useful thing that directory publishes, so it belongs pinned at the top of the branch a listener already opened, not as a second entry in a tree that is long enough already. It cost a dedicated handler (the shared _GENRE_MODULES path renders genres and nothing else) and gave back three registry entries.

Search All Sources was slow and silent. Sixteen targets through six workers is three waves; the fix is one wave (max_workers defaults to the number of targets) plus a twelve-second deadline after which stragglers are named in failed rather than dropped. The progress notice is deliberately repeating and deliberately shortens after its first sentence – a long sentence repeated every few seconds is noise a screen-reader user has to talk over.

3. Goals and non-goals

Goals

  1. Browse Stations answers from local data instantly - target under 50 ms per branch - for every source class that permits bulk data.
  2. The full supported directories ship inside the app as a compressed, versioned seed, refreshed at release-build time, so first launch is complete with no network at all.
  3. The catalog updates from live data three ways: on startup (toggleable), on a timer (interval configurable, off-able), and on demand (menu command with a spoken summary).
  4. Every automatic behavior has an off switch, and off means off: no fetch, no probe, no “just checking”.
  5. Custom and saved stations are structurally incapable of being damaged by any catalog operation. Not “carefully avoided” - stored elsewhere.
  6. The listener can always answer: how old is my catalog, what changed last time, and which sources are healthy.
  7. Cache everything that is legally allowed, and nothing that is not. The standing directive (Jeff, 2026-08-15): every source whose license or terms permit local storage joins the catalog; every source whose terms bar it stays live-drill with a session cache only - and the boundary between the two is visible in the product, not buried in this document.
  8. The listener can always tell what is local and what is live - per branch, in the UX, without noise (Section 6.5).

Non-goals (this program)

4. Source classes - the load-bearing distinction

Every source gets exactly one class, declared in code, and the class decides everything downstream: whether it is bundled, how it refreshes, what the refresh may touch.

Class A - bundled and refreshed (the catalog proper)

Bulk retrieval is permitted and practical. Shipped in the seed, refreshed live, served locally.

Class B - live-drill (cached, never bundled)

Remote trees or terms-limited APIs. Today’s behavior, unchanged: directory_cache in front, lazy expansion. TuneIn, iHeart (by policy - its terms bar entering listings into a database or distributing them; see Section 2b), Apple Podcasts, the libraries, free music (Audius/Mixcloud/ccMixter), Wikidata, YouTube listings.

Class C - user-owned (protected, never refreshed)

Favorites, custom stations, My Servers, YouTube channels the listener added, imported playlists. These live in their existing stores, are never written by any catalog code path, and are overlaid at read time (Section 8).

5. Architecture

5.1 The store: SQLite, not JSON

The catalog is a single SQLite database per profile:

%APPDATA%\Quill\radio-catalog\catalog.db

Why SQLite over the house JSON pattern: measured, not asserted (Section 2b). The full directory in SQLite answers an indexed browse query in 0.5 ms; the same data as JSON costs a 9-second, 217 MB load before the first answer. The persistence rules hold in spirit via generation files plus a pointer: refresh builds catalog.<n>.db completely, then atomically replaces a tiny CURRENT pointer file naming it. Readers resolve the pointer on open. This shape is forced by a measured Windows fact: os.replace over a database an open connection holds raises PermissionError, so the classic stage-and-swap of the .db itself cannot coexist with readers. Old generations are removed once unreferenced (next launch at the latest). A crashed refresh leaves a garbage numbered file and an untouched pointer - always consistent.

Crucially: the catalog is derived data. It can be deleted, rebuilt from the seed, or discarded on schema change with zero loss, because nothing the listener owns is in it. That single property removes the entire class of migration risk that makes databases scary.

5.2 Schema (DDL sketch)

CREATE TABLE catalog_meta(
  key   TEXT PRIMARY KEY,       -- schema_version, seed_version,
  value TEXT NOT NULL           -- seed_built_at, imported_at, app_version
);

CREATE TABLE sources(
  id             TEXT PRIMARY KEY,   -- 'radio_browser', 'xiph', ...
  class          TEXT NOT NULL,      -- 'bundled' | 'live' | 'user'
  last_refresh   TEXT,               -- ISO UTC
  last_status    TEXT,               -- 'ok'|'stale'|'rate_limited'|'error'
  last_error     TEXT NOT NULL DEFAULT '',
  station_count  INTEGER NOT NULL DEFAULT 0,
  content_hash   TEXT NOT NULL DEFAULT ''   -- change detection
);

CREATE TABLE stations(
  key              TEXT PRIMARY KEY, -- canonical key, Section 5.4
  name             TEXT NOT NULL,
  stream_url       TEXT NOT NULL,
  homepage         TEXT NOT NULL DEFAULT '',
  country          TEXT NOT NULL DEFAULT '',
  state            TEXT NOT NULL DEFAULT '',
  language         TEXT NOT NULL DEFAULT '',
  tags             TEXT NOT NULL DEFAULT '',  -- normalized, comma-joined
  codec            TEXT NOT NULL DEFAULT '',
  bitrate          INTEGER NOT NULL DEFAULT 0,
  votes            INTEGER NOT NULL DEFAULT 0, -- ranking snapshot
  source_id        TEXT NOT NULL REFERENCES sources(id),
  source_record_id TEXT NOT NULL DEFAULT '',
  first_seen       TEXT NOT NULL,
  last_seen        TEXT NOT NULL,
  vanished_at      TEXT,             -- tombstone; Section 5.6
  extra            TEXT NOT NULL DEFAULT '{}'  -- source-specific JSON
);
CREATE INDEX idx_st_geo  ON stations(country, state);
CREATE INDEX idx_st_lang ON stations(language);
CREATE INDEX idx_st_src  ON stations(source_id);

CREATE VIRTUAL TABLE stations_fts USING fts5(
  name, tags, country, language, content='stations', content_rowid='rowid'
);

CREATE TABLE merges(          -- provenance when two sources are one station
  canonical_key TEXT NOT NULL,
  member_key    TEXT NOT NULL,
  reason        TEXT NOT NULL,     -- 'same_url'|'radiodns'|'uuid'
  PRIMARY KEY(canonical_key, member_key)
);

5.3 New modules (house conventions; every one wx-free unless named ui/)

Errors: CatalogError(CodedError) with QUILL-RADIO-CATALOG-* codes (-CORRUPT, -SEED-MISSING, -REFRESH-FAILED, -SWAP-FAILED).

5.4 Canonical keys and merge rules

Key precedence, first match wins:

  1. Radio Browser station_uuid (stable, source-issued).
  2. Normalized stream URL: lowercase scheme+host, strip default ports, strip known junk query params, keep path. One pure function in keys.py, shared with nothing rewritten - favorites’ existing key logic is the reference behavior.
  3. RadioDNS service identity, when radiodns.py resolves one - as a merge link between records, never as a primary key.

Merge policy (deliberately modest for Phase 1):

5.5 The seed: building and shipping the whole directory

scripts/build_radio_catalog.py:

  1. Runs the same Class-A fetchers the app uses (they are wx-free precisely so this is possible), against live endpoints, with polite pacing.
  2. Normalizes, merges, writes a fresh seed.db.
  3. Compresses with lzma (stdlib; no new dependency) to seed.db.xz, records SHA-256 + build date in a sidecar manifest.
  4. Emits a size report and fails the build if the seed exceeds budget (Section 12) - a silently ballooning installer is a regression.

Wired into standalone/radio/scripts/build_release.ps1 exactly like docs rendering: a step before PyInstaller, with -SkipCatalog for dev builds, and the seed staged into quill/data/radio-catalog/. Both editions carry it (portable payload and shared runtime); the installer therefore works fully offline on first run.

First-run import: seed.py extracts to staging, verifies the hash, swaps in. App update with a newer seed: if the profile catalog’s seed_version lineage is older than the shipped seed, import the new seed and let the next live refresh replay any newer deltas. Simple, and correct because the catalog is derived data.

5.6 The refresh engine

Layers, mirroring what shipped for scheduling elsewhere in the app:

Mechanics:

5.7 The read path: how browsing gets fast

No UI changes at all in Phase 1 - the speed appears under the existing contract. Inside browse_sources, each Class-A handler gains a catalog-first branch:

countries      -> SELECT country, COUNT(*) ... GROUP BY country
states         -> SELECT state ... WHERE country=?
stations-by-X  -> indexed SELECT, ordered, instant
genres/tags    -> SELECT from the normalized tag column
popular        -> votes-ordered snapshot when offline; live when online
                  (rankings are the one thing that should stay live-first)

Fallback ladder per branch: catalog (if enabled and present) -> live fetch (which also write-throughs) -> directory_cache stale tier -> the existing honest empty-state messages. last_error_was_network semantics unchanged.

Find Stations gains an FTS lane: local matches appear instantly as the first group, live-source groups append as they arrive - same UI, same source column, no second surface. Search-as-you-type against FTS must stay under 30 ms.

5.8 RadioDNS and identity enrichment, baked in

core/radio/radiodns.py and its approved dnspython dependency shipped in 3.0.0 and are underused. Under the catalog they stop being a live lookup and become build-time enrichment:

6. User-facing behavior

6.1 Settings (all in RadioHistory + the Preferences dialog)

Checkbox copy follows the house voice, for example: “Keep the station catalog updated automatically” / “Check for station updates when Quill Radio starts”.

6.2 The summary, spoken and reviewable

After any refresh (manual always; automatic only when something changed):

"Station catalog updated. 174 new, 62 repaired streams, 431 details
updated, 12 removed. Two sources could not be reached."

Whole words, counts first, sources-by-name only in the review dialog. The dialog is the house ListBox pattern: one row per category, Enter expands to the per-source detail, read-only, Escape closes. “No changes” is announced for manual refresh and stays silent for automatic ones - an automatic process that talks when nothing happened is noise.

6.3 Status, on demand

View menu: “Station Catalog Status…” (Phase 3) - catalog age, station count, per-source last-refresh and health, seed version, and three buttons: Update Now, Update This Source, Rebuild From Shipped Snapshot.

6.4 Offline and Safe Mode

6.5 Telling the listener what is local and what is live

The directive: the cached/live boundary must be visible in the experience, not just in this document. The rule for doing it without noise: say it where the listener is already reading detail, never on every row.

7. What “fast” means (budgets, enforced)

8. Protection of user stations (invariants, not intentions)

  1. Separate files. User data stays in its existing stores. The catalog database contains zero user-owned rows. There is no code path from catalog/refresh.py to any user store - enforced by an import-boundary test (refresh modules may not import favorites/my_servers/youtube_channels stores).
  2. Overlay at read time. When a browse row’s canonical key matches a favorite, the favorite’s name and edits win in display, and the row is marked saved. The catalog row is untouched.
  3. Vanish never cascades. A station disappearing from a source never changes a favorite that points at it - favorites already serialize their own full station record, and that stays the rule.
  4. Rebuild is safe by construction. “Rebuild From Shipped Snapshot” deletes only radio-catalog/. A test asserts the favorites, My Servers, YouTube channels, and schedule files are byte-identical across a rebuild.
  5. Imports are user data. A playlist import lands in favorites (Class C), never in the catalog, so no refresh can ever “correct” it.
  6. The existing timestamped favorites backups continue independently.

9. Accessibility requirements (gates, not aspirations)

10. Compliance and egress

11. Failure handling (the degradation matrix)

Failure Behavior
Catalog file corrupt Coded error logged once; auto-rebuild from seed; browse falls to live for that session; one calm announcement
Seed missing/hash mismatch (tampered or truncated install) Catalog disabled with a status message; live browsing unaffected; never import an unverified seed
One source down during refresh Source marked stale with its age; every other source refreshes; summary names it
Rate limited Honor Retry-After; back off that source for the day; count it in the summary as “waiting”, not “failed”
Disk full mid-refresh Staging write fails; swap never happens; existing catalog intact; announced once
Refresh interrupted (sleep, shutdown) Staging discarded on next start; catalog is whatever was last swapped in - always consistent

12. Packaging and size budget

13. Testing strategy

14. Phased delivery (PR-sized, in order)

Phase 0 - measure before promising - DONE 2026-08-15

Findings are Section 2b; the prototype lives in local/catalog-proto/ (fetchers, the built 26 MB catalog, the 6.4 MB seed, the benchmark scripts). Outcomes folded into this document: 10 MB seed budget, pointer-based swap, URL-merge tightening, empty-source outage rule, per-page refresh parsing, the (country, votes DESC) covering index, and mandatory pagination for the Radio Browser dump. The iHeart terms read closed Phase 0 entirely: iHeart is Class B by policy (Section 2b). Nothing remains open in this phase.

Phase 1 - the catalog exists and browsing is instant (2-3 PRs)

  1. catalog/ core: store, keys, merge, seed import, coded errors + tests.
  2. Seed builder script + release-script wiring + size gate + egress entries.
  3. Catalog-first read path for Radio Browser axes, Xiph, SomaFM (+ iHeart if cleared), behind catalog_enabled; parity + perf tests; the master checkbox in Preferences.

Exit criteria: fresh install, network cable pulled, every Class-A branch browses instantly; suite green; installer size within budget.

Phase 2 - it stays fresh, and says so (2 PRs)

  1. Refresh engine: startup toggle, interval, staggering, tombstones, write-through; the two new Preferences checkboxes; minute-tick wiring.
  2. “Update Station Catalog…” command, summary model, spoken summary, review dialog; inventories regenerated.

Exit criteria: John’s machine can sit for a week and the catalog is current; turning both toggles off produces zero background requests (verified by the egress-silent test lane).

Phase 3 - visibility, search, and the adopted extensions (2-3 PRs)

  1. FTS lane in Find Stations (instant local group first).
  2. Catalog Status view: age, health, per-source refresh, rebuild button; “what’s new since last update” as a browse branch that reads the diff; the cached-vs-live UX of Section 6.5 (details-panel line, Choose Browse Sources descriptions, the once-per-session offline sentence).
  3. The library seed (Class A2, adopted): Gutenberg audio seeded and served locally. LibriVox measured out of the v1 seed (see the delivery log: 194,501 section rows = 60 MB against the whole 10 MB budget); its branches stay live, and seeding it behind a compact section format is the named follow-up.
  4. RadioDNS enrichment at build time (5.8): the identities table and the Wikidata frequency join in the seed builder.

Phase 4 - explicitly deferred until the above is proven

15. Risks

Risk Mitigation
Seed bloats the installers Hard size gate in the build; assets-on-demand split ready as the relief valve
A source changes its export format Per-source isolation + hash short-circuit; worst case one stale source, never a broken app
SQLite on network/portable drives misbehaves with WAL Portable edition detection already exists; fall back to journal mode DELETE on portable media; swap semantics unchanged
Merge collapses two real stations into one Refusal-to-merge on ambiguity; provenance table makes any merge inspectable and reversible on rebuild
Scope creep toward the delight features before the engine is solid Phase gates above; Phase 4 items are named and parked, not smuggled

16. Open questions for Jeff

  1. (Closed 2026-08-15: iHeart terms read; Class B permanently. See 2b.)
  2. Default periodic interval: 24 hours is proposed; 12 felt eager for a directory that changes slowly. Preference?
  3. Should popular/trending fall back to the snapshot when offline (proposed: yes, labeled “as of ”), or hide rather than show stale rankings?
  4. Seed in both editions, or portable-only with the installer relying on first-run refresh? (Proposed: both; offline-first is the story.)

Appendix A - freely available sources for Jeff to consider

Filtered hard by the standing rule: if hoops are required - keys, registrations, partner agreements, terms that bar storage - forget it. Everything below was checked on 2026-08-15; the two marked verified live were fetched and measured today.

Include - no hoops, storage permitted

Consider with one caution each

Excluded by the no-hoops rule - listed so the reasoning is visible

Delivery log

17. Decision log

12. Parity with the sibling app (3.0.0)

Four gaps, all of the same shape: something QUILL Cast could do for a podcast that Quill Radio could not do for a station, in an app the same person uses for the same job an hour apart. None of them is a new idea. What each one needed was a decision about where the shared code is born.

12.1 The rule about shared code

New shared modules under quill/core/ are written as new code Radio consumes, and Cast migrates onto them on its own release schedule. That buys one release of duplication in exchange for a Radio branch whose blast radius stops at Radio – the alternative, extracting from Cast first, puts a Cast regression inside Radio’s release window with no Cast release to catch it.

Two such modules exist now, and Cast has since migrated onto both:

quill/ui/media/quick_actions_dialog.py is the shared reorder window; each app supplies its own catalogue and labels.

Cast’s core/podcasts/stats.py keeps its own on-disk shape deliberately. It has been accumulating real listening history since 1.1.0 under its own field names; rewriting that to the shared record’s spelling would gain tidiness and risk somebody’s history, for a file nobody can recompute. What was genuinely duplicated – the period table and the way a duration is spoken – now comes from the shared module, so “this week” cannot come to mean two different weeks in two apps a listener compares.

12.2 Quick Actions (R1)

Three contexts – station, recording, browse node – each with the first action as Enter’s default and the first nine on Ctrl+1..9.

The catalogue’s ids are row_actions’ own ids, verbatim, and a test fails the build if Radio’s catalogue ever offers one no row menu builds. An action a listener can put first and then never reach is a preference that silently does nothing.

The preference orders what a row already offers and never adds to it. A station already in Favorites offers Remove and not Add; a live stream offers no Download. Ranking Download first means Download is first on the rows that have it.

12.3 Listening statistics (R2)

Keyed the way favorites.py keys a station, so the totals and the favorites list are talking about the same thing and a renamed favorite does not fork into two rows. The network travels alongside as the group, because “four hours of ACB Media” is a fact about a network no per-station row can add up.

Radio gets its own dialog rather than Cast’s with rows suppressed: suppression means editing a Cast dialog to serve Radio, which is the dependency 12.1 exists to avoid, and the suppressed version is more code than the honest one because every hidden row is a condition.

Two rules about what counts:

Speed and silence-trimming are omitted, not zeroed: neither means anything for a live broadcast.

12.4 The Cast handoff (R3)

Episode rows gain Play Next, Add to Queue and Send to Inbox, carried in radio-actions.jsona second file, not a field on the existing one.

This is a forward-compatibility decision with a verified failure behind it. radio_listens._read keeps any record carrying a non-empty audio key, and merge_radio_listens consumes every record it can match, writing only unmatched ones back. A record written by a new Radio as an action field on the listens record and read by an already-installed Cast is matched, does nothing, and is then deleted – losing the queue intent silently. That is the common case, not an edge one, because Radio ships first and every Cast in the field is the old one. A file an old Cast never opens leaves the backlog intact.

12.5 Favorites folders, and chapters (R4, A3)

A favorites folder gains Play All, Shuffle and Export. Playing one leaves a queue behind that Next and Previous walk, because a live station never ends and there is nothing for a sequential playlist to advance on. Shuffle is a fixed permutation, so Previous retraces it. The ends of a folder are announced rather than wrapped.

Chapters: Radio reads and never computes. The file’s own frames first, then the result QUILL Cast left in the shared inference cache – so work done once in Cast is available in Radio without doing it twice. Radio is the lite app and is not gaining a speech engine to answer a question its sibling has already answered. Where a local file exists, the list offers Preview This Mark: ten seconds either side of the boundary, through its own player, without moving the listener’s place. Both sides, because the question is whether the programme turns there, and that needs the end of what came before.

13. The columns are the sentence (3.0.0)

A report list is read out one column at a time. That makes the column set of a list not a display preference but a speech setting: whoever chose the columns chose what a listener hears on every row of a list they arrow through hundreds of times a day. Quill Radio made that choice once, in code, for everybody – somebody who never leaves one country heard a country on all sixty thousand rows, and somebody who browses by network heard the source last.

View > Choose Columns… (Ctrl+Alt+Shift+C) hands the choice over, for Find Stations results and for the Recordings list.

13.1 Four properties, each ruling out a failure

13.2 The window

Two lists – Shown, in the order they are read and Hidden – with Move Up / Move Down and Hide / Show, never checkboxes inside a list. That is the same rule Quick Actions follows and for the same reason: a checkbox in a list is a state a screen reader has to be asked for, while a list position is a place you land on, and the announcement after a move says where you are now.

Under the lists, one line reads out exactly what a row will say, comma-separated, because that is how a screen reader runs a report row’s cells together. It is the announcement rather than a picture of it, so somebody deciding whether to hide Country can hear the answer before pressing OK.

13.3 What is offered but not shown

A default list that says everything says nothing, so the catalogue holds more than the default shows. Find Stations adds Language, Genres, Popularity and Bitrate; Recordings adds Length – blank wherever the minutes a capture carries is a disk-safety cap rather than a length somebody chose, because announcing a cap as a plan states an intention nobody expressed. tests/unit/core/radio/test_radio_list_columns.py fails the build if the catalogue offers a column no fill site produces: a column somebody can switch on and then hear nothing from is worse than one that was never offered.

13.4 Shared, deliberately

The machinery (core/media/list_columns.py, ui/media/list_columns_dialog.py, ui/media/list_columns_view.py) is shared with QUILL Cast, which gets the same window on its own catalogue. Quick Actions, listening statistics and folder actions each had to be ported between the two apps after being built twice; this one was built shared on the first day. Each app keeps its own store file, because the two catalogues have no ids in common and a shared file would mean each app’s repair pass silently discarding the other’s layout.

Not built, deliberately: per-column widths as a saved preference (the list control already lets somebody drag a column and Windows remembers nothing about it, which is the existing behaviour and not a regression), and a universal version of this across every list in QUILL – named as the next step rather than done here, because the two apps a listener uses for the same job an hour apart are where the inconsistency actually hurt.

14. F1 answers everywhere: the context-help system (2026-08-23)

Requirement. Press F1 on any control, in any Quill Radio window, and a help window opens carrying two things, read by a screen reader in one pass: the purpose of the window you are standing in, then the purpose of the control under focus and how to drive its kind. The display is a read-only, multi-line text field – reviewable by character, copyable – with Escape returning focus exactly where it was. This models the QUILL editor’s F1 experience; the two apps share the same help window class so they read identically.

Where the words live. Three layers compose, and each has one home:

  1. Surface purposes – one authored paragraph per window title, in quill/core/radio/surface_help.py (PURPOSES, with PREFIX_PURPOSES for live titles like “Now Playing: WQXR”). Keyed by window title because the title is the identity a window already announces, the raise-if-open guards already key on it, and it is what a listener quotes in a bug report.
  2. Control helpSetHelpText beside the control it describes, in the surface’s own module. Where none is authored, the control’s accessible name stands in – in this app the names are already teaching sentences (“Reload the highlighted source from the internet”).
  3. Role usage – one sentence per control kind (“A tree: Up and Down move between rows, Right expands…”) from surface_help.role_usage, so even a bare control teaches its keyboard.

Wiring is inherited, not per-dialog. quill/ui/dialog_contract.py binds F1 on every window that passes through its two show paths – which, by the existing dialog gate, is every window – whenever an app has registered a handler via set_context_help_handler. Quill Radio registers at startup (quill/ui/radio/context_help.activate()), which also installs wx.SimpleHelpProvider: without a provider, SetHelpText silently stores nothing (measured), so every help text in the codebase was dead until this. The main window binds F1 directly and carries Help > What Is This? (F1); the User Guide moved to Ctrl+F1 (QUILL’s convention) and the PRD to Ctrl+Alt+F1.

The gate (GATE-RADIO-HELP). quill/tools/radio_help_audit.py, enforced by tests/unit/tools/test_radio_help_audit.py:

Regenerate the inventory with python -m quill.tools.radio_help_audit --write and review the diff; the tool run also prints title violations and missing sites directly.

15. What both listening apps owe a listener (2026-08-24)

Eleven requirements that arrived together because most of them are one answer shared between Quill Radio and QUILL Cast rather than two answers that happen to agree today. Where a section below says “shared”, the store, the module and the wording are literally one thing, and a change to either app changes both.

15.1 Nothing is a dead end

Requirement. A command that cannot run says why, at the moment somebody asks for it.

This family dims a great deal on purpose: Mark All as Played with nothing unheard is a state of a verb the row owns, which is exactly why it dims rather than vanishing – a verb that comes and goes teaches nobody anything. But a screen reader says “dimmed” and stops, and the item itself said nothing about what would un-dim it. That is a dead end you cannot see around.

Every dimmed action now carries a reason: one lower-case clause naming the condition, with a count wherever a count makes it concrete.

15.2 One step of undo, in preference to a prompt on every verb

Requirement. The last destructive action can be taken back, once, and the offer is spoken.

A confirmation prompt is the cheap answer and a poor one: it costs a keystroke and a sentence every single time, including the nine hundred times the listener meant it, and it still cannot help the one time the wrong row was focused. One step of undo is cheaper than a prompt on every verb and kinder than either.

15.3 A verb that touches many rows says how many

Requirement. Every action on more than one row ends by announcing exact eligible / done / skipped counts, with the skipped reason beside the number.

“Removed downloads” tells a listener who cannot see the list neither how many files went nor whether the ones it could not touch were mentioned.

15.4 A failure that was spoken once still exists

Requirement. Feeds, downloads, recordings and streams that fail are written down, with reason, time and a way to try again.

Announcements are transient by design, which is right until the sentence you needed went past while you were in another window. That was the one place this family was not screen-reader-first: a sighted listener still had a list to scroll back through and a listener who missed the speech had nothing at all.

15.5 Quiet hours, and the line they must not cross

Requirement. One window, shared by both apps, in which the apps stop speaking on their own.

Unprompted is the whole distinction, and it is a hard rule. Quiet hours must never silence the answer to a keypress: a listener who presses Play at three in the morning is entitled to hear what is playing, because they asked. What is held back is speech nobody asked for – check ticks, new-episode notices, download notices, reminders. This is why the implementation is a small vocabulary of kinds each call site opts into (quill/core/quiet_hours.py) rather than a gate around _announce: a gate would silence replies as well, and no amount of care at the call sites would make that safe.

15.6 The setup is portable, and says what it is not carrying

Requirement. One file carries everything this machine has been taught, out and back in.

OPML moves subscriptions and nothing else – which is to say it moves the part that was easy to standardise and leaves the part somebody actually built.

15.7 A place is a place, whichever app you were in

Requirement. An episode started in one app picks up in the other, on this machine, at the position the listener last decided on.

Listening Places already defined what a place is between devices. What was missing was one shared local store and the write on pause – Radio never wrote a position when you paused at all, so its handoff to Cast reported wherever you had last pressed Stop.

15.8 Adding something you already have

Requirement. Say so, name it, and move to the row that already exists.

The reason anybody adds a thing twice is that they could not find the first one, so a refusal that leaves the cursor where it was answers the wrong question. The root cause on the Radio side was a store that answered None whether or not it had added anything, so every caller announced success over nothing happening.

15.9 Find an episode inside one show

Requirement. Search the episodes of the show you are on, over titles and descriptions, composing with the state filter and the sort, announcing the match count on submit.

Between “filter by state” and a cross-library search there was nothing, so which episode of this show was the one about the harbour had no answer but arrowing two hundred rows. Descriptions are in scope because a show that numbers its episodes and puts the subject in the notes – most interview podcasts – is precisely where a title-only search finds nothing.

15.10 Timeline verbs mean the same thing in both players

Requirement. One parser and one dialog for “go to this position”, and one key for Skip Silence.

15.11 Every surface says what it is for

Requirement. F1 in QUILL Cast opens with an authored paragraph about the window, exactly as Quill Radio’s has since 3.0 – and a new window cannot ship without one.

The family-wide F1 engine had worked everywhere since 2026-08-23 and only Radio had authored purposes, so every Cast window answered with the generic sentence: true, and useless.

GATE-CAST-HELP (quill/tools/cast_help_audit.py) mirrors GATE-RADIO-HELP over the podcast UI. The scanner and snapshot rules both gates share now live in quill/tools/help_audit.py, so the two cannot drift.

16. A published schedule must be shown, and shown at the right time (2026-08-24)

Two requirements that the ACB Media Schedule window turned out not to meet. Both were found by reading the live feed, and neither would have been found by a test written from the same assumptions as the code.

16.1 A time is read in the zone it was written in

Requirement. A calendar time that states its zone is read in that zone. Only a genuinely floating time – no Z, no TZID – falls back to UTC.

The window read every time as UTC and then rendered it in the reader’s own zone (calendar_actions.clock), so the entire schedule sat five hours early everywhere but UTC. The failure has no edge to catch on: every programme moves by the same amount, so the schedule still reads as a schedule.

The fallback stays, and its reasoning is unchanged – guessing the reader’s zone for a floating time is wrong by a different amount on every machine, where being consistently wrong is at least correctable. What changed is that a stated zone is no longer treated as an absent one. A zone this machine cannot resolve degrades to the old reading rather than dropping the event.

16.2 A published programme is never silently absent

Requirement. Expanding a repeat rule may drop repeats it cannot interpret. It may never drop the programme.

DTSTART is the first instance of a recurrence set (RFC 5545 3.8.5.3), and it is kept even when the rule’s own bounds exclude everything – which is exactly what ACB’s first real recurring entry does, its UNTIL falling before the event it is attached to. The expander already had this instinct in one place: an unreadable or unsupported FREQ returns the event unchanged rather than nothing. This extends it to a rule that is perfectly readable and produces an empty set.

Two limits keep it honest: an anchor outside the requested window is not dragged into it, and an EXDATE naming the anchor still cancels it, because that is somebody saying so on purpose rather than a bound that happens to exclude everything.

16.3 The fixture is verbatim

Requirement. A fixture standing in for a live feed is captured byte-for-byte, including whitespace and parameters that look decorative.

Both faults above live in exactly the parts a tidied-up fixture loses: a TZID parameter that looks like metadata, and the double spacing ACB’s exporter emits. tests/unit/core/radio/fixtures/acb-2026-08-recurring.ics is three events copied out of the live feed untouched.

17. A schedule that is empty must say why (2026-08-24)

Reported the same day section 16 landed: “nothing is showing up when arrowing to today. There appears to be no data.”

There was data. The window was a week – Sunday to Saturday, a heading for each day, paged with Previous/Next – and the week containing 24 August was empty, because ACB publishes a fortnight of listings at a time and then stops. The live feed on 23 and again on 24 August ran out on 15 August, and ACB’s own calendar page showed the same. Nothing was broken and nothing said so.

17.1 The shape follows the data, not the noun

Requirement. A view of a schedule is a list of what is published, not a grid of every date that could hold something.

A calendar shape makes a claim: that these cells are the schedule, and an empty one means nothing is on. For a source that posts in bursts, most cells are empty most of the time, and the claim is false in the one direction that matters – it reads as an app with no data rather than as a source with none.

The window is now one list, ordered by date, every row carrying its own date, time, programme and channel; it opens on the next programme still to come. The week’s filters survive and gain one: a Date picker offering only the dates that actually have programmes, each with its count, which is the “jump to a day” the week paging was really for.

17.2 The summary always states the published range

Requirement. The window says how far the published listings run, on every load, and says plainly when that is already behind us.

“49 programmes published. The published schedule runs 1 August to 15 August. Nothing is published for today or later – ACB last posted a schedule through 15 August.” A reader who cannot see the list gets the same fact the list would have given a reader who can scroll it, and the difference between “no data yet” and “broken” stops being something they have to work out.

17.3 An unpublished month falls back to the published one

Requirement. When the requested month has no listings, show the previous month’s rather than nothing – and never present it as current.

My Calendar serves a month window, so on the 1st of a month ACB has not posted yet, the only schedule that exists is last month’s. The summary line from 17.2 is what makes this safe: an older schedule arrives already labelled with its own range.

17.4 Programme titles are read as text, not as markup

Requirement. Text from a feed reaches the screen reader as characters.

ACB’s feed is generated from WordPress content and arrives double-encoded: a curly apostrophe reaches us as &amp;#8217;, which a screen reader reads out as “ampersand hash eight two one seven semicolon” in the middle of a programme title. The parser now decodes entities after RFC 5545 unescaping, bounded at three passes and stopped as soon as it settles, so a title that legitimately contains a literal escaped ampersand is not eaten.

17.5 The schedule belongs on the Community menu

Requirement. A surface lives on the menu named for what it is.

The three schedule items were on Station, on the argument that a schedule is about what is on. Station is the menu of everything Quill Radio can tune and it had passed twenty items. The Community menu is “places this community already goes, brought inside the app”, which is precisely what an ACB Media schedule is; it now sits beside ACB Community Events. The menu is built even when the ADP assistant is switched off, because Radio always has the schedule.

17.6 Play is a toggle, and its label says which

Requirement. A Play verb aimed at the channel already playing stops it.

It called play_station regardless, which tears the stream down and rebuilds it: a few seconds of silence, the same audio back, and no way to stop from this window at all. The verb now reads Stop when the highlighted programme’s channel is the one playing, on the button and on the context menu alike – both are built from one actions_for call given the player’s current channel, so the label and the action cannot disagree. Channels are matched by number (acb_calendar.same_stream), because the calendar’s category says “ACB Media 5” while the playable station’s own name need not be spelled identically, and a programme naming no channel matches nothing.

17.7 A marker on every row marks nothing

Requirement. A row annotation distinguishes that row from the others.

Rows carried a “finished” suffix for anything already past. Because ACB publishes a fortnight and stops, that was routinely every row in the window – a word repeated forty-nine times that separated no row from any other, and one more thing to listen past on each. It is gone; the date at the front of the row already says it. “on now” stays, because only one row can ever say it.

17.8 A row says both ends of the programme

Requirement. A listing that carries a start and an end shows both.

A start time alone answers “when do I tune in” and leaves “how long is this” to be found by opening the row. For a schedule somebody is planning an evening around, the length is half of what they came for. ACB’s listings carry DTEND on nearly everything; where they do not, the row shows one time rather than inventing a second.

17.9 The summary is focusable

Requirement. A sentence that explains what a window is showing can be returned to.

The summary was a StaticText, which cannot be tabbed to, arrowed through, or re-read a word at a time – so somebody who missed it as it was spoken had no way back to it, and it is the one place the empty-list reason lives. It is now a read-only multi-line wx.TextCtrl: read-only rather than disabled, because a disabled control refuses focus as well as edits.

18. The Command Palette shows the keys (2026-08-24)

Requirement. Every command the palette lists shows and speaks its keystroke, if it has one.

A Command carries a binding only when whoever registered it passed one, and the companion apps register most of theirs with a title and a handler alone – their keys live in APP_KEYMAPS, which is what the menu labels are built from. So Quill Radio’s menus showed the keys correctly while its palette showed none at all, which is the wrong way round: the palette is where somebody goes to find a command, and it is the one place teaching the keystroke costs nothing.

CommandPaletteDialog now takes an optional binding resolver and asks the keymap first, falling back to whatever the registration carried. Reading the keymap rather than the registry also means a rebind reaches the palette, which it did not before. QUILL passes the same resolver, so the fix is family-wide.

19. The main window shows what you chose (2026-08-24)

Reported in two steps. First: “the favorites and browse window are showing, shouldn’t favorites be hidden unless called upon?” Then, exactly: “shouldn’t we be able to show any of the windows in the main frame of the app with a menu bar based on the selection in settings?”

Yes. The setting existed and was answering the wrong question.

19.1 The choice is what the main window shows, not what else opens

Requirement. Choosing a surface puts you in it, with the menu bar.

startup_window opened one chosen window over the main one, so the listener got two windows before pressing anything – and the menu bar was on the one they had not asked for, because the main window is the only surface that carries it. A peer window’s menu bar is &Close plus &Window.

The main window’s middle – between the now-playing line and the volume row – is now a wx.Simplebook whose pages are Favorites (the built-in tree), Browse, Search, Recordings and the Player. Everything else about the frame is unchanged, which is what makes this a smaller change than it sounds: the menu bar, the now-playing readout, Mute and Volume, the status bar, the tray and the transport keys are all frame-level and all shared.

main_view migrates from startup_window, so somebody who chose Browse at launch opens in Browse. An upgrade must not take away a surface somebody chose, and must not add one they did not.

19.2 A hosted surface is the same surface

Requirement. Browse behaves identically whether it is a window or the main view.

Each surface class gained one keyword (embed_in) and one method (focus_default_control). What an embedded surface does not get is a frame, a menu bar of its own, a Close button or an entry in the window list – the main window already has all four.

The host builds them by calling the app’s own openers with embed_in set, not by copying their argument lists. Those lists are fifteen keywords long and carry real decisions – which download queue, which catalog, which visible sources – so a copy would drift, and drift invisibly: Browse-as-a-window and Browse-as-the-main-view would quietly differ.

19.3 A menu item whose surface is the main view goes there

Requirement. Ctrl+B while Browse is the main window focuses it.

Otherwise the original complaint returns in a new place: a second copy stacked on top of the one already in front of you. Each opener asks _main_view_is first; hosts without a main view (embedded QUILL) answer False and behave exactly as before.

19.4 The main window is never empty

Requirement. A surface that will not build leaves the window on whatever it was showing, and says so.

An empty main window is the one state a listener cannot get out of by keyboard. An unreadable stored value reads as Favorites, and a failed build announces the fallback and stores it – a checkmark on a view that failed is a menu lying about where you are.

19.5 Built once, kept

Requirement. Switching back shows the page you left.

Rebuilding is simpler and throws away the tree somebody spent time expanding, which is the same reason the browse tree remembers its position at all.