From fb94f609519573c85b49eb74952c73503617a534 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 19 Sep 2026 11:12:53 +0800 Subject: [PATCH 1/5] Add backlog for live and local radio coverage Asking for voices in Dhaka lands on one dead station. Measured causes: Bangladesh has 26 directory entries against 23 brands Wikidata knows, 12 of those entries carry no tags at all, and both Jago FM rows share one dead stream. Co-Authored-By: Claude Opus 5 --- ...-75 - Find-voices-for-untagged-stations.md | 24 ++++++++++++++++++ ...ountry-to-its-language-before-giving-up.md | 24 ++++++++++++++++++ ...ort-which-stations-a-country-is-missing.md | 24 ++++++++++++++++++ ...on-source-behind-the-provider-interface.md | 25 +++++++++++++++++++ ...ions-that-are-not-relays-of-one-another.md | 23 +++++++++++++++++ 5 files changed, 120 insertions(+) create mode 100644 backlog/tasks/task-75 - Find-voices-for-untagged-stations.md create mode 100644 backlog/tasks/task-76 - Widen-a-thin-country-to-its-language-before-giving-up.md create mode 100644 backlog/tasks/task-77 - Report-which-stations-a-country-is-missing.md create mode 100644 backlog/tasks/task-78 - Add-a-second-station-source-behind-the-provider-interface.md create mode 100644 backlog/tasks/task-79 - Prefer-stations-that-are-not-relays-of-one-another.md diff --git a/backlog/tasks/task-75 - Find-voices-for-untagged-stations.md b/backlog/tasks/task-75 - Find-voices-for-untagged-stations.md new file mode 100644 index 0000000..0ac18e9 --- /dev/null +++ b/backlog/tasks/task-75 - Find-voices-for-untagged-stations.md @@ -0,0 +1,24 @@ +--- +id: TASK-75 +title: Find voices for untagged stations +status: To Do +assignee: [] +created_date: '2026-09-19 03:08' +labels: [] +dependencies: [] +ordinal: 75000 +--- + +## Description + + +Mode filtering reads tags, and 12 of Bangladesh's 26 stations carry no tags at all, so they can never match 'voices' whatever they broadcast. Only 3 BD entries are talk-tagged and one of those is the dead Jago FM duplicate. Extend the existing nameSuggestsSpeech signal with language and station-name evidence so untagged stations are judged on what is known about them rather than dropped. + + +## Acceptance Criteria + +- [ ] #1 An untagged station whose name reads as speech matches voices mode +- [ ] #2 Stations with tags keep their current behaviour +- [ ] #3 Measured voices-mode pool size for BD, IN, EG, MX, TR recorded before and after +- [ ] #4 No measured increase in music stations leaking into voices mode + diff --git a/backlog/tasks/task-76 - Widen-a-thin-country-to-its-language-before-giving-up.md b/backlog/tasks/task-76 - Widen-a-thin-country-to-its-language-before-giving-up.md new file mode 100644 index 0000000..4f30a64 --- /dev/null +++ b/backlog/tasks/task-76 - Widen-a-thin-country-to-its-language-before-giving-up.md @@ -0,0 +1,24 @@ +--- +id: TASK-76 +title: Widen a thin country to its language before giving up +status: To Do +assignee: [] +created_date: '2026-09-19 03:08' +labels: [] +dependencies: [] +ordinal: 76000 +--- + +## Description + + +Pinned to one country the app refuses rather than silently handing over music, which is right, but in Bangladesh it means 'voices' almost always refuses. Before refusing, offer speech stations in the country's own languages from anywhere: Bengali voices from West Bengal or the diaspora are closer to what was asked for than nothing. Say plainly that the station is from elsewhere. + + +## Acceptance Criteria + +- [ ] #1 A thin country falls back to its languages before returning 404 +- [ ] #2 The response marks the station as from outside the chosen country +- [ ] #3 The UI says where it went instead of the country asked for +- [ ] #4 A country with enough stations of its own is unaffected + diff --git a/backlog/tasks/task-77 - Report-which-stations-a-country-is-missing.md b/backlog/tasks/task-77 - Report-which-stations-a-country-is-missing.md new file mode 100644 index 0000000..083abf0 --- /dev/null +++ b/backlog/tasks/task-77 - Report-which-stations-a-country-is-missing.md @@ -0,0 +1,24 @@ +--- +id: TASK-77 +title: Report which stations a country is missing +status: To Do +assignee: [] +created_date: '2026-09-19 03:08' +labels: [] +dependencies: [] +ordinal: 77000 +--- + +## Description + + +Radio Browser lists 26 stations for Bangladesh. Wikidata lists 23 Bangladeshi radio brands, and the ones the directory has never heard of include Radio Today, ABC Radio, Radio Capital, Radio Ekattor, Colours FM, Radio Aamar, Radio Dhoni, City FM and Radio Padma: most of the commercial FM dial in Dhaka. Wikidata is free, keyless and has a real SPARQL endpoint, but it carries no stream URLs, so it cannot be a station source. Use it for what it is good for: a per-country gap report that gives the seed list a real work queue instead of guesses. + + +## Acceptance Criteria + +- [ ] #1 A script queries Wikidata for radio stations in a given country +- [ ] #2 It reports which known brands our directory does not carry +- [ ] #3 Output is committed for Bangladesh as the first worked example +- [ ] #4 No runtime page depends on Wikidata + diff --git a/backlog/tasks/task-78 - Add-a-second-station-source-behind-the-provider-interface.md b/backlog/tasks/task-78 - Add-a-second-station-source-behind-the-provider-interface.md new file mode 100644 index 0000000..e4c7601 --- /dev/null +++ b/backlog/tasks/task-78 - Add-a-second-station-source-behind-the-provider-interface.md @@ -0,0 +1,25 @@ +--- +id: TASK-78 +title: Add a second station source behind the provider interface +status: To Do +assignee: [] +created_date: '2026-09-19 03:08' +labels: [] +dependencies: [] +ordinal: 78000 +--- + +## Description + + +getStationProvider already hides the directory behind one interface, so a second source is a matter of composing providers rather than rewriting callers. Add a seed provider reading a list held in the repo, merged with Radio Browser and deduped by stream URL, so stations the directory misses can be carried without waiting on it. Every seed entry must pass our own probe in CI: scraping found Radio Today's stream on zeno.fm, but it answers 401 to any third-party client, so an unverified list would ship stations that cannot play. + + +## Acceptance Criteria + +- [ ] #1 A seed provider reads stations from a file in the repo +- [ ] #2 Seed and Radio Browser results merge and dedupe by stream URL +- [ ] #3 A test fails if a seed entry is malformed +- [ ] #4 A CI job probes every seed stream and reports the dead ones +- [ ] #5 Seeded with whatever Bangladeshi stations actually verify + diff --git a/backlog/tasks/task-79 - Prefer-stations-that-are-not-relays-of-one-another.md b/backlog/tasks/task-79 - Prefer-stations-that-are-not-relays-of-one-another.md new file mode 100644 index 0000000..4c6e828 --- /dev/null +++ b/backlog/tasks/task-79 - Prefer-stations-that-are-not-relays-of-one-another.md @@ -0,0 +1,23 @@ +--- +id: TASK-79 +title: Prefer stations that are not relays of one another +status: To Do +assignee: [] +created_date: '2026-09-19 03:08' +labels: [] +dependencies: [] +ordinal: 79000 +--- + +## Description + + +Asking for voices tends to land on the same few networks because news is syndicated: the BBC World Service, RFI and their relays are filed many times over. Measured, BBC is only 4% of the talk-tagged pool, so the fix is not to single it out but to stop drawing the same brand twice. Spread the choice across distinct station names and hosts so repeated presses give genuinely different radio. + + +## Acceptance Criteria + +- [ ] #1 Two draws in a row do not return the same brand name +- [ ] #2 Candidates are spread across stream hosts, not just shuffled +- [ ] #3 Measured repeat rate over 200 draws recorded before and after + From e6cd3552a7f83477182fad211436cf30acba7c0f Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 19 Sep 2026 11:12:53 +0800 Subject: [PATCH 2/5] Collapse stations that share a stream URL The directory is community edited, so a station is often filed twice with one stream between the rows. Both Jago FM entries in Bangladesh point at the same stream and it is off the air, so asking for voices in Dhaka spent two of its attempts on the same silence. Measured: Bangladesh 26 entries to 24, popular top 500 to 482. Co-Authored-By: Claude Opus 5 --- ...llapse-stations-that-share-a-stream-URL.md | 31 ++++++ lib/stations/dedupe.test.ts | 102 ++++++++++++++++++ lib/stations/dedupe.ts | 71 ++++++++++++ lib/stations/normalize.test.ts | 11 ++ lib/stations/normalize.ts | 5 +- 5 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 backlog/tasks/task-74 - Collapse-stations-that-share-a-stream-URL.md create mode 100644 lib/stations/dedupe.test.ts create mode 100644 lib/stations/dedupe.ts diff --git a/backlog/tasks/task-74 - Collapse-stations-that-share-a-stream-URL.md b/backlog/tasks/task-74 - Collapse-stations-that-share-a-stream-URL.md new file mode 100644 index 0000000..7af1859 --- /dev/null +++ b/backlog/tasks/task-74 - Collapse-stations-that-share-a-stream-URL.md @@ -0,0 +1,31 @@ +--- +id: TASK-74 +title: Collapse stations that share a stream URL +status: Done +assignee: [] +created_date: '2026-09-19 03:08' +updated_date: '2026-09-19 03:12' +labels: [] +dependencies: [] +ordinal: 74000 +--- + +## Description + + +Two entries pointing at the same audio waste a listener's attempt twice. Measured: Jago FM and Spice FM are each filed twice in Bangladesh with an identical stream URL, and Jago FM's is dead, so 'voices in Dhaka' burns both its tries on the same silence. Globally 50 of 480 popular entries (10%) are an exact duplicate of another entry's stream. Dedupe on the normalized stream URL when a list is built, keeping the better documented entry (tags, bitrate, votes), so alternates are genuinely different stations. + + +## Acceptance Criteria + +- [ ] #1 Stations sharing a normalized stream URL collapse to one entry +- [ ] #2 The kept entry is the one with the most tags, then bitrate, then votes +- [ ] #3 Surprise alternates contain no two stations with the same stream +- [ ] #4 Measured before/after duplicate rate is recorded in the task + + +## Implementation Notes + + +Measured against the live directory: Bangladesh 26 raw entries -> 24 (8% shared a stream), popular top 500 -> 482 (4%), popular offset 2000 -> 494 (1%). Both Jago FM rows and both Spice FM rows collapse, so asking for voices in Dhaka no longer spends two attempts on the same dead stream. + diff --git a/lib/stations/dedupe.test.ts b/lib/stations/dedupe.test.ts new file mode 100644 index 0000000..9be5309 --- /dev/null +++ b/lib/stations/dedupe.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { betterEntry, dedupeByStream, streamKey } from "@/lib/stations/dedupe"; +import type { Station } from "@/lib/stations/types"; + +function station(overrides: Partial & { id: string; streamUrl: string }): Station { + return { + name: overrides.id, + languages: [], + tags: [], + isHls: false, + votes: 0, + clickCount: 0, + lastCheckOk: true, + source: "radio-browser", + ...overrides, + } as Station; +} + +describe("streamKey", () => { + it("treats http and https on one host as the same audio", () => { + expect(streamKey("http://example.com/stream")).toBe(streamKey("https://example.com/stream")); + }); + + it("ignores www and a trailing slash", () => { + expect(streamKey("https://www.example.com/stream/")).toBe(streamKey("https://example.com/stream")); + }); + + it("ignores the default port but keeps a real one", () => { + expect(streamKey("http://example.com:80/s")).toBe(streamKey("http://example.com/s")); + // Shoutcast hosts run many stations on one address, split by port. + expect(streamKey("http://1.2.3.4:8000/s")).not.toBe(streamKey("http://1.2.3.4:9000/s")); + }); + + it("keeps the query, which often selects the mount", () => { + expect(streamKey("https://h/play?id=1")).not.toBe(streamKey("https://h/play?id=2")); + }); + + it("gives an unparseable address its own identity rather than collapsing them", () => { + expect(streamKey("not a url")).not.toBe(streamKey("also not a url")); + }); +}); + +describe("betterEntry", () => { + const base = { id: "a", streamUrl: "https://h/s" }; + + it("prefers the entry the directory believes is up", () => { + const up = station({ ...base, id: "up" }); + const down = station({ ...base, id: "down", lastCheckOk: false, tags: ["talk"] }); + expect(betterEntry(down, up).id).toBe("up"); + }); + + it("prefers the described entry, because an untagged one cannot be matched", () => { + const bare = station({ ...base, id: "bare", bitrate: 320 }); + const tagged = station({ ...base, id: "tagged", tags: ["talk", "news"], bitrate: 64 }); + expect(betterEntry(bare, tagged).id).toBe("tagged"); + }); + + it("falls back to bitrate, then votes", () => { + const quiet = station({ ...base, id: "quiet", bitrate: 64, votes: 99 }); + const loud = station({ ...base, id: "loud", bitrate: 128, votes: 1 }); + expect(betterEntry(quiet, loud).id).toBe("loud"); + + const few = station({ ...base, id: "few", votes: 1 }); + const many = station({ ...base, id: "many", votes: 500 }); + expect(betterEntry(few, many).id).toBe("many"); + }); +}); + +describe("dedupeByStream", () => { + it("collapses the two Jago FM rows that share a dead stream", () => { + // Both rows are real entries in the live Bangladesh listing, and asking for + // voices in Dhaka spent two attempts on the same silent stream. + const stations = [ + station({ id: "1", name: "Jago FM 94.4", streamUrl: "http://139.59.86.99:12496/stream" }), + station({ id: "2", name: "Jago Fm", streamUrl: "http://139.59.86.99:12496/stream", tags: ["pop", "talk"] }), + ]; + const kept = dedupeByStream(stations); + expect(kept).toHaveLength(1); + expect(kept[0].name).toBe("Jago Fm"); + }); + + it("keeps the caller's popularity order", () => { + const stations = [ + station({ id: "first", streamUrl: "https://a/s" }), + station({ id: "second", streamUrl: "https://b/s" }), + station({ id: "dup", streamUrl: "https://a/s", tags: ["talk"] }), + ]; + expect(dedupeByStream(stations).map((s) => s.id)).toEqual(["dup", "second"]); + }); + + it("leaves distinct stations alone", () => { + const stations = [ + station({ id: "1", streamUrl: "https://a/s" }), + station({ id: "2", streamUrl: "https://b/s" }), + ]; + expect(dedupeByStream(stations)).toHaveLength(2); + }); + + it("handles an empty list", () => { + expect(dedupeByStream([])).toEqual([]); + }); +}); diff --git a/lib/stations/dedupe.ts b/lib/stations/dedupe.ts new file mode 100644 index 0000000..7e12d5f --- /dev/null +++ b/lib/stations/dedupe.ts @@ -0,0 +1,71 @@ +import type { Station } from "@/lib/stations/types"; + +/* + * The same audio, filed twice. + * + * Radio Browser is community edited, so a station often arrives more than once: + * someone adds "Jago Fm", someone else adds "Jago FM 94.4", and both rows carry + * the identical stream address. Measured against the live directory, 50 of 480 + * popular entries (10%) duplicate another entry's stream, and in Bangladesh both + * of Jago FM's rows point at a stream that is off the air. Asking for voices in + * Dhaka therefore spent two of its attempts on the same silence. + * + * Collapsing on the stream rather than the name is what makes the alternates + * list worth having: four fallbacks are only four chances if they are four + * different stations. + */ + +/** + * What identifies the audio, ignoring how the row spells it. Protocol is left + * out because the same host serving http and https is one station, not two, and + * we already prefer the https address when playing. + */ +export function streamKey(url: string): string { + try { + const parsed = new URL(url); + const port = parsed.port && parsed.port !== "80" && parsed.port !== "443" ? `:${parsed.port}` : ""; + const host = parsed.hostname.replace(/^www\./, "").toLowerCase(); + const path = parsed.pathname.replace(/\/+$/, ""); + return `${host}${port}${path}${parsed.search}`; + } catch { + // Not parseable: treat the raw string as its own identity rather than + // collapsing every malformed row into one. + return url.trim().toLowerCase(); + } +} + +/** + * Which of two rows for the same stream to keep. Tags decide first because a + * described station can be matched to a mood or a listening mode, and an + * untagged one is invisible to both however healthy it is. + */ +export function betterEntry(a: Station, b: Station): Station { + if (a.lastCheckOk !== b.lastCheckOk) return a.lastCheckOk ? a : b; + if (a.tags.length !== b.tags.length) return a.tags.length > b.tags.length ? a : b; + const aBitrate = a.bitrate ?? 0; + const bBitrate = b.bitrate ?? 0; + if (aBitrate !== bBitrate) return aBitrate > bBitrate ? a : b; + if (a.votes !== b.votes) return a.votes > b.votes ? a : b; + return a; +} + +/** + * One entry per stream, keeping the order the caller gave us: the directory + * hands lists back in popularity order and dropping to a better documented + * duplicate should not move the station up or down that list. + */ +export function dedupeByStream(stations: Station[]): Station[] { + const positions = new Map(); + const kept: Station[] = []; + for (const station of stations) { + const key = streamKey(station.streamUrl); + const seen = positions.get(key); + if (seen === undefined) { + positions.set(key, kept.length); + kept.push(station); + } else { + kept[seen] = betterEntry(kept[seen], station); + } + } + return kept; +} diff --git a/lib/stations/normalize.test.ts b/lib/stations/normalize.test.ts index a98c581..f081ebd 100644 --- a/lib/stations/normalize.test.ts +++ b/lib/stations/normalize.test.ts @@ -115,6 +115,17 @@ describe("normalizeStations", () => { it("returns an empty list for non arrays", () => { expect(normalizeStations({})).toEqual([]); }); + + it("collapses separate entries that carry the same stream", () => { + // Different ids and names, one stream: the directory is community edited + // and the same station is often filed twice. + const list = normalizeStations([ + { ...base, stationuuid: "a", name: "Jago FM 94.4", tags: "" }, + { ...base, stationuuid: "b", name: "Jago Fm", tags: "pop,talk" }, + ]); + expect(list).toHaveLength(1); + expect(list[0].name).toBe("Jago Fm"); + }); }); describe("splitList", () => { diff --git a/lib/stations/normalize.ts b/lib/stations/normalize.ts index 939c090..c78f0ba 100644 --- a/lib/stations/normalize.ts +++ b/lib/stations/normalize.ts @@ -1,3 +1,4 @@ +import { dedupeByStream } from "@/lib/stations/dedupe"; import type { Facet, Station } from "@/lib/stations/types"; /** Subset of the Radio Browser station JSON we rely on. Every field may be missing. */ @@ -135,7 +136,7 @@ export function normalizeStation(raw: RawRadioBrowserStation): Station | null { }; } -/** Normalize a list, dropping unusable entries and duplicate ids. */ +/** Normalize a list, dropping unusable entries, duplicate ids and duplicate streams. */ export function normalizeStations(raw: unknown): Station[] { if (!Array.isArray(raw)) return []; const seen = new Set(); @@ -148,7 +149,7 @@ export function normalizeStations(raw: unknown): Station[] { stations.push(station); } } - return stations; + return dedupeByStream(stations); } /** Normalize facet lists (countries, languages, tags), sorted by station count. */ From dca7cf72b45f99189efefb5805939c1680726a8a Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 19 Sep 2026 11:14:44 +0800 Subject: [PATCH 3/5] Format dedupe files with Prettier Co-Authored-By: Claude Opus 5 --- lib/stations/dedupe.test.ts | 11 +++++++++-- lib/stations/dedupe.ts | 3 ++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/stations/dedupe.test.ts b/lib/stations/dedupe.test.ts index 9be5309..c35497f 100644 --- a/lib/stations/dedupe.test.ts +++ b/lib/stations/dedupe.test.ts @@ -22,7 +22,9 @@ describe("streamKey", () => { }); it("ignores www and a trailing slash", () => { - expect(streamKey("https://www.example.com/stream/")).toBe(streamKey("https://example.com/stream")); + expect(streamKey("https://www.example.com/stream/")).toBe( + streamKey("https://example.com/stream"), + ); }); it("ignores the default port but keeps a real one", () => { @@ -72,7 +74,12 @@ describe("dedupeByStream", () => { // voices in Dhaka spent two attempts on the same silent stream. const stations = [ station({ id: "1", name: "Jago FM 94.4", streamUrl: "http://139.59.86.99:12496/stream" }), - station({ id: "2", name: "Jago Fm", streamUrl: "http://139.59.86.99:12496/stream", tags: ["pop", "talk"] }), + station({ + id: "2", + name: "Jago Fm", + streamUrl: "http://139.59.86.99:12496/stream", + tags: ["pop", "talk"], + }), ]; const kept = dedupeByStream(stations); expect(kept).toHaveLength(1); diff --git a/lib/stations/dedupe.ts b/lib/stations/dedupe.ts index 7e12d5f..a5319bc 100644 --- a/lib/stations/dedupe.ts +++ b/lib/stations/dedupe.ts @@ -23,7 +23,8 @@ import type { Station } from "@/lib/stations/types"; export function streamKey(url: string): string { try { const parsed = new URL(url); - const port = parsed.port && parsed.port !== "80" && parsed.port !== "443" ? `:${parsed.port}` : ""; + const port = + parsed.port && parsed.port !== "80" && parsed.port !== "443" ? `:${parsed.port}` : ""; const host = parsed.hostname.replace(/^www\./, "").toLowerCase(); const path = parsed.pathname.replace(/\/+$/, ""); return `${host}${port}${path}${parsed.search}`; From 0974a20872a6ace060e2f838645e7c1a0656204b Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 19 Sep 2026 11:24:44 +0800 Subject: [PATCH 4/5] Give each mock station its own stream in the e2e fixtures Every mock station shared one stream URL, so collapsing duplicate streams correctly reduced them to a single entry and the end-to-end expectations no longer held. Real stations have their own streams. Co-Authored-By: Claude Opus 5 --- e2e/mock-radio-browser.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/e2e/mock-radio-browser.mjs b/e2e/mock-radio-browser.mjs index 61835e2..4b5635c 100644 --- a/e2e/mock-radio-browser.mjs +++ b/e2e/mock-radio-browser.mjs @@ -8,8 +8,10 @@ const ORIGIN = `http://127.0.0.1:${PORT}`; const station = (id, name, countrycode, country, tags, language = "") => ({ stationuuid: id, name, - url: `${ORIGIN}/stream.wav`, - url_resolved: `${ORIGIN}/stream.wav`, + // Each station gets its own stream: real ones do, and entries that share a + // stream are deliberately collapsed as duplicates of each other. + url: `${ORIGIN}/stream/${id}.wav`, + url_resolved: `${ORIGIN}/stream/${id}.wav`, homepage: "https://example.com/", favicon: "", country, @@ -129,7 +131,7 @@ function search(params) { createServer((req, res) => { const url = new URL(req.url, ORIGIN); const path = url.pathname; - if (path === "/stream.wav") { + if (path.startsWith("/stream/") && path.endsWith(".wav")) { res.writeHead(200, { "content-type": "audio/wav", "content-length": WAV.length, From c1d055466c732eeb0b19acb9aad59301719a8257 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 19 Sep 2026 11:25:24 +0800 Subject: [PATCH 5/5] Fix unrecorded bitrates being read as bad ones The directory writes 0 when it has no bitrate on file, so the quality floor dropped the station outright. Of the live stations measured, 43% in Bangladesh, 45% in Egypt and 50% in Nigeria carry no bitrate, and it fell hardest on the stations the voices mode exists to find. Bangladesh offered three voice stations out of six, two of them Quran recitations and the third off the air. Radio Vivid Voice and Spice FM are tagged talk and were dropped only for missing metadata. Unknown now passes and the stream probe decides. A bitrate that is on file and below the floor is still refused. Measured voices pool: BD 3 to 6, EG 2 to 9, NG 5 to 18, KE 4 to 10. Co-Authored-By: Claude Opus 5 --- ...ding-an-unrecorded-bitrate-as-a-bad-one.md | 42 +++++++++++++++++++ lib/stations/listening-mode.test.ts | 18 ++++++++ lib/stations/listening-mode.ts | 19 ++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 backlog/tasks/task-80 - Stop-reading-an-unrecorded-bitrate-as-a-bad-one.md diff --git a/backlog/tasks/task-80 - Stop-reading-an-unrecorded-bitrate-as-a-bad-one.md b/backlog/tasks/task-80 - Stop-reading-an-unrecorded-bitrate-as-a-bad-one.md new file mode 100644 index 0000000..2ce1c32 --- /dev/null +++ b/backlog/tasks/task-80 - Stop-reading-an-unrecorded-bitrate-as-a-bad-one.md @@ -0,0 +1,42 @@ +--- +id: TASK-80 +title: Stop reading an unrecorded bitrate as a bad one +status: Done +assignee: [] +created_date: '2026-09-19 03:14' +updated_date: '2026-09-19 03:25' +labels: [] +dependencies: [] +priority: high +ordinal: 80000 +--- + +## Description + + +meetsQualityFloor compares station.bitrate to a floor, and the directory writes 0 when it simply has not recorded a bitrate. Unknown is therefore judged as 0 kbps and the station is dropped. Measured share of live stations with no recorded bitrate: Bangladesh 43%, India 49%, Egypt 45%, Nigeria 50%. + +This is the main reason Dhaka had no voices. Bangladesh has 6 speech stations; 3 are offered, and those 3 are two Quran recitation channels plus Jago FM, whose stream is dead. The three dropped ones, including Radio Vivid Voice and Spice FM which are tagged talk outright, were dropped only because no bitrate is on file. Nigeria loses 13 of its 18 speech stations the same way. + +Treat an unrecorded bitrate as unknown rather than as zero, and let the stream probe decide, since it already rejects anything that does not answer. Rank stations with a known good bitrate above unknown ones so quality still wins where it is known. + + +## Acceptance Criteria + +- [ ] #1 A station with no recorded bitrate is not dropped by the quality floor +- [ ] #2 A station with a recorded bitrate below the floor is still dropped +- [ ] #3 Known-good bitrates are preferred over unknown ones in ordering +- [ ] #4 Measured voices pool for BD, EG, NG, MX, TR, CU recorded before and after + + +## Implementation Notes + + +Measured voices pool per country, before -> after: +BD 3 -> 6, EG 2 -> 9, NG 5 -> 18, KE 4 -> 10, MX 3 -> 5, PK 8 -> 10, CU 1 -> 2, TR 9 -> 10, VN 14 -> 17, IN 8 -> 8. +Music pools roughly double in the same countries (IN 37 -> 83, NG 25 -> 43). + +Dhaka now offers six voice stations instead of three, and the three added are the ones tagged talk outright: Radio Vivid Voice, Spice FM and Universal Health Radio. + +On the third acceptance criterion: the candidate pool is deliberately NOT partitioned into known-bitrate first. Doing so would bury the unknown stations behind the same few known ones, which in Bangladesh means the probe would keep landing on the two Quran channels and the dead Jago FM: the exact complaint this task exists to fix. Quality preference is applied where it costs no variety, in dedupe's betterEntry, which keeps the row with a recorded bitrate when two entries describe one stream. The stream probe decides the rest. + diff --git a/lib/stations/listening-mode.test.ts b/lib/stations/listening-mode.test.ts index ba58d3b..cff14dd 100644 --- a/lib/stations/listening-mode.test.ts +++ b/lib/stations/listening-mode.test.ts @@ -57,6 +57,24 @@ describe("the quality floor", () => { it("does not let a floor of zero through", () => { expect(meetsQualityFloor(talk("a", 0))).toBe(false); }); + + it("lets a station through when no bitrate was ever recorded", () => { + // The directory writes 0 when it has nothing on file, which normalizing + // turns into undefined. Reading that as 0 kbps dropped 43% of Bangladesh's + // live stations and 50% of Nigeria's, including talk-tagged ones. Unknown + // is not bad: the stream probe decides whether it actually answers. + const unknown = makeStation("unknown", { + countryCode: "BD", + tags: ["talk"], + bitrate: undefined, + }); + expect(meetsQualityFloor(unknown)).toBe(true); + }); + + it("still refuses a bitrate that is on file and below the floor", () => { + expect(meetsQualityFloor(music("a", 32))).toBe(false); + expect(meetsQualityFloor(talk("b", 16))).toBe(false); + }); }); describe("matchesMode", () => { diff --git a/lib/stations/listening-mode.ts b/lib/stations/listening-mode.ts index 0d0f1f5..fcde446 100644 --- a/lib/stations/listening-mode.ts +++ b/lib/stations/listening-mode.ts @@ -206,8 +206,25 @@ export function minBitrateFor(station: Station): number { return isSpeechStation(station) ? MIN_SPEECH_BITRATE : MIN_MUSIC_BITRATE; } +/* + * An unrecorded bitrate is not a bad one. + * + * The directory writes 0 when it has no bitrate on file, which normalizing + * turns into undefined. Reading that as 0 kbps failed the floor and dropped the + * station outright, and the gap is not small: of the live stations measured, + * 43% in Bangladesh, 45% in Egypt, 49% in India and 50% in Nigeria carry no + * bitrate at all. It fell hardest on exactly the stations this mode exists to + * find. Bangladesh has six speech stations and offered three, all because + * Radio Vivid Voice and Spice FM, both tagged talk, had nothing on file. + * Nigeria lost thirteen of eighteen the same way. + * + * So unknown passes, and the stream probe decides: it already rejects anything + * that does not answer, which is the thing a listener actually cares about. A + * bitrate that is on file and genuinely below the floor is still refused. + */ export function meetsQualityFloor(station: Station): boolean { - return (station.bitrate ?? 0) >= minBitrateFor(station); + if (station.bitrate === undefined) return true; + return station.bitrate >= minBitrateFor(station); } /** Does this station suit what the listener asked for? */