diff --git a/android/app/src/main/assets/phone/companion.html b/android/app/src/main/assets/phone/companion.html index 415bc4a..537d5d9 100644 --- a/android/app/src/main/assets/phone/companion.html +++ b/android/app/src/main/assets/phone/companion.html @@ -250,10 +250,12 @@ function more() { const yours = state.channels, pr = state.profiles, kids = pr?.current?.kind === 'Kids'; const colours = ['#45B8F5', '#FF4FA3', '#6EDCAA', '#FFB347', '#B98CFF']; let h = head('More'); - if (pr) h += `
Who's watching
${pr.profiles.map((p, i) => ``).join('')} - ${pr.pinSet ? 'Adult needs the PIN' : ''}
`; + ${kids ? '' : ``} +
${kids ? '' : 'Edit profiles'} + ${pr.pinSet ? 'Adult needs the PIN' : ''}
`; if (kids) return h + `
Kids is watching. Making channels and the Director are for Adult.
`; const waiting = state.directorCount; h += ``; } +function profileAddSheet() { + const a = state.padd || {kind: 'Grown'}; + const choice = (k, title, line) => ``; + return `
Add someone
They get their own Continue watching and picks. You can change anything later in Settings › Family.
+ + ${choice('Grown', 'Adult', 'Everything, with After Dark behind the PIN')}${choice('Kids', 'Kids', "Only Orbit's kids channels to start, never After Dark")} + `; +} +function profileEditSheet() { + const ps = state.profiles?.profiles || [], grown = ps.filter(p => p.kind === 'Grown').length; + return `
Profiles
Rename or remove. Pictures and Kids' channels are in Settings › Family on the TV.
` + + ps.map(p => `
${p.kind === 'Kids' ? 'Kids' : 'Adult'}
+
+
+ ${p.kind === 'Grown' && grown === 1 ? '
The only Adult stays.
' : `Remove`}
`).join(''); +} // Who's watching (phase 3): Kids goes straight in; Adult asks for the PIN on the phone when one's set, as the TV does. async function switchTo(id, pin) { buzz(); @@ -687,7 +705,7 @@ document.addEventListener('change', e => { state.create.draft.season[k] = +e.target.value; render(); }); document.addEventListener('click', e => { - const b = e.target.closest('button,[data-tab],a[data-kseechosen],a[data-kusename]'); if (!b) return; + const b = e.target.closest('button,[data-tab],a[data-kseechosen],a[data-kusename],a[data-pedit],a[data-premove]'); if (!b) return; if (b.tagName === 'A') e.preventDefault(); const d = b.dataset; if (d.tab) { if (d.tab !== 'more' || tab === 'more') { state.create = null; state.chair = false; } tab = d.tab; try { localStorage.setItem('orbit.tab', tab); } catch {} closeSheet(); render(); if (tab !== 'remote') load(tab); return; } @@ -703,6 +721,19 @@ document.addEventListener('click', e => { if (d.place) { const [id, channel] = d.place.split(':'); send('/api/director/place', {id, channel: +channel}, j => { toast(j.message); state.director.choices = state.director.choices.filter(c => c.id !== id); render(); }); return; } if (d.drop) { send('/api/director/drop', {id: d.drop}, j => { toast(j.message); state.director.choices = state.director.choices.filter(c => c.id !== d.drop); render(); }); return; } if (d.profile) { switchTo(d.profile); return; } + // Family on the phone (7 November 2026): add someone, rename, remove, as Settings › Family on the TV. + if (d.paddnew) { state.padd = {kind: 'Grown'}; openSheet(profileAddSheet()); setTimeout(() => $('#pname')?.focus(), 50); return; } + if (d.pkind) { state.padd.kind = d.pkind; state.padd.name = $('#pname')?.value || ''; openSheet(profileAddSheet()); return; } + if (d.paddgo) { const name = ($('#pname')?.value || '').trim(); if (!name) { toast('Give them a name first'); return; } + api('/api/profile/add', {name, kind: state.padd.kind, picture: state.padd.kind === 'Kids' ? 'ollie' : 'sil-pink'}) + .then(r => { state.profiles = r; closeSheet(); toast(name + ' added'); render(); }).catch(e => toast(e.message)); return; } + if (d.pedit) { openSheet(profileEditSheet()); return; } + if (d.prename) { const v = ($('#pn-' + d.prename)?.value || '').trim(); if (!v) return; + api('/api/profile/rename', {id: d.prename, name: v}).then(r => { state.profiles = r; toast('Renamed'); openSheet(profileEditSheet()); render(); }).catch(e => toast(e.message)); return; } + if (d.premove) { const p = state.profiles.profiles.find(x => x.id === d.premove); + openSheet(`
Remove ${esc(p?.name || '')}?
Their Continue watching and picks go too. Channels and schedules stay.
+ `); return; } + if (d.premoveyes) { api('/api/profile/remove', {id: d.premoveyes}).then(r => { state.profiles = r; toast('Removed'); openSheet(profileEditSheet()); render(); }).catch(e => toast(e.message)); return; } if (d.pinok) { switchTo(d.pinok, $('#pin').value); return; } if (d.kback) { const c = state.create, v = c.view; if (v === 'start' || (v === 'shows' && c.draft.number)) state.create = null; diff --git a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt index 01918cf..7ff4414 100644 --- a/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt +++ b/android/app/src/main/java/tv/orbit/classic/ChannelScheduler.kt @@ -83,7 +83,9 @@ internal class ChannelScheduler(private val store: ScheduleStore, * the last 60 days, read in batches and appended to the evidence file, skipping any already read. Read only. */ fun readEvidence(session: EmbySampleSession, userId: String, catalogue: CatalogueStore) { - if (!EvidenceRequest.take()) return + // Daily as well as on request (Paul, 7 November 2026): new titles in a collection reach the Chair on their own. + val refresh = CollectionRefresh(store.collectionsFile()) + if (!EvidenceRequest.take() && !refresh.due(clock())) return val today = java.time.LocalDate.now() val ids = SmartChannels.flatMap { store.loadPool(userId, it.number)?.series.orEmpty().map { s -> s.id } } + catalogue.releasedBetween(today.minusDays(NEWLY_RELEASED_DAYS).toString(), today.toString(), 2_000).map { it.id } + @@ -105,19 +107,24 @@ internal class ChannelScheduler(private val store: ScheduleStore, // Read before collections were paged (every one stopped at 300): set aside once and read again in full. if (collections.exists() && collections.useLines { lines -> lines.firstOrNull()?.contains("\"complete\"") != true }) collections.renameTo(java.io.File(collections.path + ".before-paged")) - val done = if (collections.exists()) collections.useLines { it.count() } else 0 - var start = done; var sets = 0; var total = -1 + // The first full read appends to the file; after that each day's refresh reads into a fresh copy, swapped in once whole, + // so a collection's new members (and any removed) are picked up without ever leaving a half-read list in use. + val refreshing = refresh.complete() + val target = if (refreshing) refresh.next else collections + val done = if (target.exists()) target.useLines { it.count() } else 0 + var start = done; var sets = 0; var total = -1; var finished = false try { while (sets < COLLECTIONS_MAX_PER_RUN) { val (page, all) = session.readCollections(start, COLLECTIONS_PAGE) total = all - if (page.isEmpty()) break - collections.appendText(page.joinToString("") { it.toString() + "\n" }) + if (page.isEmpty()) { finished = true; break } + target.appendText(page.joinToString("") { it.toString() + "\n" }) start += page.size; sets += page.size - if (start >= all) break + if (start >= all) { finished = true; break } } } catch (failure: EmbyCheckException) { Log.w("OrbitSchedule", "collections failed status=${failure.httpStatus}") } - Log.i("OrbitSchedule", "collections read=$sets total=$total kept=${done + sets}") + if (finished) refresh.finish(refreshing, clock()) + Log.i("OrbitSchedule", "collections read=$sets total=$total kept=${done + sets} refresh=$refreshing finished=$finished") } /** diff --git a/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt index 310780c..17b59c6 100644 --- a/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt +++ b/android/app/src/main/java/tv/orbit/classic/EvidenceReader.kt @@ -48,3 +48,25 @@ internal fun evidenceMissing(file: File, ids: List): List { val have = if (file.exists()) file.useLines { lines -> lines.mapNotNull { runCatching { JSONObject(it).optString("id") }.getOrNull() }.toHashSet() } else emptySet() return ids.distinct().filter { it !in have } } + +/** + * The daily collections refresh (Paul, 7 November 2026). Until the first full read finishes, reads go on appending to the + * collections file as before; from then on, once a day, the whole list is read again into [next] and swapped in when complete, + * a bounded number of sets per background round. [state] holds when the last full read finished. + */ +internal class CollectionRefresh(private val file: java.io.File) { + val next = java.io.File(file.path + ".next") + private val state = java.io.File(file.path + ".state") + private fun finishedAt(): Long? = runCatching { JSONObject(state.readText()).getLong("finishedAt") }.getOrNull() + /** A first full read has finished, so refreshes go to [next]. */ + fun complete() = finishedAt() != null + /** Something to do: the first read isn't finished, a refresh is part-way, or the last full read is a day old. */ + fun due(nowMs: Long): Boolean = finishedAt()?.let { next.exists() || nowMs - it >= COLLECTIONS_REFRESH_MS } ?: true + /** A full read reached the end: a refresh replaces the file; either way the time is noted. */ + fun finish(refreshing: Boolean, nowMs: Long) { + if (refreshing && next.exists()) { if (!next.renameTo(file)) { file.writeText(next.readText()); next.delete() } } + state.writeText(JSONObject().put("finishedAt", nowMs).toString()) + } +} + +internal const val COLLECTIONS_REFRESH_MS = 24 * 60 * 60_000L diff --git a/android/app/src/main/java/tv/orbit/classic/Idents.kt b/android/app/src/main/java/tv/orbit/classic/Idents.kt index 04bfd4e..d363019 100644 --- a/android/app/src/main/java/tv/orbit/classic/Idents.kt +++ b/android/app/src/main/java/tv/orbit/classic/Idents.kt @@ -38,6 +38,18 @@ private val IdentVideos = mapOf(108 to R.raw.ident_108, 109 to R.raw.ident_109, 611 to R.raw.ident_611, 612 to R.raw.ident_612, 613 to R.raw.ident_613) /** [atMs] is when the next programme starts, for a channel whose ident follows the time of day. */ +/** + * Idents with a sound of their own (Paul, 7 November 2026; design/channels/sound): played with Ident sound on, at most once + * every 20 minutes a channel, so a run of short programmes doesn't repeat it every few minutes. + */ +internal val IdentsWithSound = setOf(R.raw.ident_mistletoe, R.raw.ident_sakura, R.raw.ident_109, R.raw.ident_110) +internal object IdentStings { + private val last = HashMap() + const val GAP_MS = 20 * 60_000L + @Synchronized fun take(number: Int, nowMs: Long): Boolean = + (last[number]?.let { nowMs - it >= GAP_MS } ?: true).also { if (it) last[number] = nowMs } +} + internal fun identVideo(number: Int, atMs: Long = 0) = if (number == TOTS_NUMBER) totsVideo(atMs) else IdentVideos[number] ?: CustomChannels.of(number)?.brand?.let { BrandPackIdents[it] } internal fun identLength(number: Int, atMs: Long = 0) = when (number) { TOTS_NUMBER -> totsMood(atMs).lengthMs @@ -120,7 +132,8 @@ internal fun IdentOverlay(session: EmbySampleSession, channel: SampleChannel, ne } seekTo(startedLateMs.coerceIn(0, identMs)) volume = if (block != null) (if (settings.blockIdentSound) 1f else 0f) - else if (mood != null && settings.identSound && TotsJingle.take(mood, System.currentTimeMillis())) 1f else 0f + else if (mood != null && settings.identSound && TotsJingle.take(mood, System.currentTimeMillis())) 1f + else if (mood == null && video in IdentsWithSound && settings.identSound && IdentStings.take(channel.number, System.currentTimeMillis())) 1f else 0f prepare(); playWhenReady = true } } diff --git a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt index 5bfb140..3f63eb7 100644 --- a/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt +++ b/android/app/src/main/java/tv/orbit/classic/PhoneLink.kt @@ -463,7 +463,8 @@ internal object PhoneLink { private val companionRoutes = setOf("GET /api/now", "GET /api/guide", "GET /api/catchup", "GET /api/rooms", "GET /api/looks", "POST /api/key", "POST /api/volume", "POST /api/channel", "POST /api/tune", "POST /api/section", "POST /api/catchup/watch", "GET /api/queue", "POST /api/queue", "POST /api/queue/remove", "GET /api/drivein", "POST /api/drivein/ticket", "POST /api/drivein/watch", - "GET /api/director", "POST /api/director/place", "POST /api/director/drop", "POST /api/director/idea", "GET /api/profiles", "POST /api/profile") + "GET /api/director", "POST /api/director/place", "POST /api/director/drop", "POST /api/director/idea", "GET /api/profiles", "POST /api/profile", + "POST /api/profile/add", "POST /api/profile/rename", "POST /api/profile/remove") /** The Companion's remote, guide, Catch Up and rooms (see [OrbitCompanion]); false when [r] is for Create your own. */ private fun companion(r: Request, out: OutputStream): Boolean { if ("${r.method} ${r.path}" !in companionRoutes) return false @@ -515,6 +516,25 @@ internal object PhoneLink { if (why != null) return json(out, 403, JSONObject().put("error", why)) json(out, 200, OrbitCompanion.profiles()) } + // Family on the phone (Paul, 7 November 2026), as Settings › Family on the TV: adults only. + "POST /api/profile/add", "POST /api/profile/rename", "POST /api/profile/remove" -> { + if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult to change profiles.")) + val ctx = appContext ?: return json(out, 409, JSONObject().put("error", "Orbit is starting up on the TV.")) + val o = body(); var problem: String? = null + onScreen { + when (r.path) { + "/api/profile/add" -> if (Profiles.all.size >= MAX_PROFILES) problem = "Orbit holds $MAX_PROFILES profiles." + else Profiles.add(ctx, o.optString("name"), if (o.optString("kind") == "Kids") ProfileKind.Kids else ProfileKind.Grown, + o.optString("picture").takeIf { it in ProfilePictures } ?: if (o.optString("kind") == "Kids") "ollie" else "sil-pink") + "/api/profile/rename" -> Profiles.all.firstOrNull { it.id == o.optString("id") }?.let { Profiles.update(ctx, it.copy(name = cleanProfileName(o.optString("name")))) } + ?: run { problem = "That profile has gone." } + else -> { val id = o.optString("id") + if (Profiles.remove(ctx, id)) runCatching { forgetProfileData(Workspaces.current(ctx).scheduleDir, id) } else problem = "Someone has to stay Adult." } + } + } + problem?.let { return json(out, 409, JSONObject().put("error", it)) } + json(out, 200, OrbitCompanion.profiles()) + } "GET /api/director", "POST /api/director/place", "POST /api/director/drop", "POST /api/director/idea" -> { if (!OrbitCompanion.grownUp) return json(out, 403, JSONObject().put("error", "Kids is watching. Switch to Adult for the Director.")) val report = runCatching { s.director() }.getOrNull() diff --git a/android/app/src/main/java/tv/orbit/classic/Settings.kt b/android/app/src/main/java/tv/orbit/classic/Settings.kt index 425dd7b..289b2d7 100644 --- a/android/app/src/main/java/tv/orbit/classic/Settings.kt +++ b/android/app/src/main/java/tv/orbit/classic/Settings.kt @@ -279,7 +279,7 @@ private fun ViewingDetail(firstSwitch: FocusRequester, modifier: Modifier, top: SettingLine("Idents", "Play each channel's ident between programmes. When off, Tots shows a still picture instead.") { OnOff(idents, Modifier, onClick = { idents = !idents; settings.idents = idents }) } - SettingLine("Ident sound", "Let Tots play its jingle, at most once every 30 minutes.") { + SettingLine("Ident sound", "Let Tots play its jingle, and channels with a sound of their own (Mistletoe, Sakura, Signature, Unscripted) play theirs, never too often.") { OnOff(identSound, Modifier, onClick = { identSound = !identSound; settings.identSound = identSound }) } SettingLine("Special idents", "Give named blocks and events their own ident as they begin, such as Riot Laughs or PPV Sunday. Off uses each channel's usual ident.") { diff --git a/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt index c5aabbd..26142ca 100644 --- a/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt +++ b/android/app/src/main/java/tv/orbit/classic/SmartSetup.kt @@ -236,6 +236,8 @@ internal fun shapedParts(slots: Map, eveningPage: Long = 0): List< // Evenings are appointments (Paul, 7 November 2026: Sakura's evening had 85 shows, each on once a week): at most // EVENING_ROTATION shows a week, so each comes back most nights, and a different set the next week until all have had theirs. bySlot[Slot.Evening] = eveningRotation(bySlot.getValue(Slot.Evening), eveningPage) + // Late nights the same way (Paul, 7 November 2026: Sakura's late had 52 shows), with a few more as the hours are longer. + bySlot[Slot.Late] = eveningRotation(bySlot.getValue(Slot.Late), eveningPage, LATE_ROTATION) val gentle = bySlot.getValue(Slot.Daytime) + bySlot.getValue(Slot.Evening) return Slot.entries.mapNotNull { s -> val titles = bySlot.getValue(s).ifEmpty { if (s == Slot.Late) gentle + bySlot.getValue(Slot.Late) else gentle } @@ -245,12 +247,14 @@ internal fun shapedParts(slots: Map, eveningPage: Long = 0): List< /** How many shows a shaped channel's evening carries in a week. */ internal const val EVENING_ROTATION = 12 +/** How many shows a shaped channel's late nights carry in a week: eight hours a night, and enough that its films needn't repeat. */ +internal const val LATE_ROTATION = 24 /** This week's evening shows from [titles]: all of them when there are few, else the week's turn of [EVENING_ROTATION]. */ -internal fun eveningRotation(titles: List, page: Long): List { - if (titles.size <= EVENING_ROTATION) return titles +internal fun eveningRotation(titles: List, page: Long, cap: Int = EVENING_ROTATION): List { + if (titles.size <= cap) return titles val sorted = titles.sortedBy { seriesKey(it) } - val pages = (sorted.size + EVENING_ROTATION - 1) / EVENING_ROTATION + val pages = (sorted.size + cap - 1) / cap // Spread evenly, so the last week isn't a handful of leftovers. val per = (sorted.size + pages - 1) / pages val k = Math.floorMod(page, pages.toLong()).toInt() diff --git a/android/app/src/main/res/raw/ident_109.mp4 b/android/app/src/main/res/raw/ident_109.mp4 index bb25828..130e707 100644 Binary files a/android/app/src/main/res/raw/ident_109.mp4 and b/android/app/src/main/res/raw/ident_109.mp4 differ diff --git a/android/app/src/main/res/raw/ident_110.mp4 b/android/app/src/main/res/raw/ident_110.mp4 index f481858..0b42ee9 100644 Binary files a/android/app/src/main/res/raw/ident_110.mp4 and b/android/app/src/main/res/raw/ident_110.mp4 differ diff --git a/android/app/src/main/res/raw/ident_mistletoe.mp4 b/android/app/src/main/res/raw/ident_mistletoe.mp4 index 4a49d13..5127078 100644 Binary files a/android/app/src/main/res/raw/ident_mistletoe.mp4 and b/android/app/src/main/res/raw/ident_mistletoe.mp4 differ diff --git a/android/app/src/main/res/raw/ident_sakura.mp4 b/android/app/src/main/res/raw/ident_sakura.mp4 index ea43cc0..add8c59 100644 Binary files a/android/app/src/main/res/raw/ident_sakura.mp4 and b/android/app/src/main/res/raw/ident_sakura.mp4 differ diff --git a/android/app/src/test/java/tv/orbit/classic/CollectionRefreshTest.kt b/android/app/src/test/java/tv/orbit/classic/CollectionRefreshTest.kt new file mode 100644 index 0000000..5c079a2 --- /dev/null +++ b/android/app/src/test/java/tv/orbit/classic/CollectionRefreshTest.kt @@ -0,0 +1,34 @@ +package tv.orbit.classic + +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +/** Collections refresh daily (7 November 2026): the first read appends, later ones read a fresh copy and swap it in whole. */ +class CollectionRefreshTest { + @get:Rule val tmp = TemporaryFolder() + private val day = 24 * 60 * 60_000L + + @Test fun refreshesOnceADayAndSwapsInWhole() { + val file = java.io.File(tmp.root, "collections-0.jsonl") + val r = CollectionRefresh(file) + assertTrue("the first read is always due", r.due(0)); assertFalse(r.complete()) + file.writeText("old\n") + r.finish(refreshing = false, nowMs = 1_000) + assertTrue(r.complete()); assertFalse(r.due(1_000 + day - 1)); assertTrue(r.due(1_000 + day)) + // A refresh part-way is carried on at the next round, and the old list stays in use until it's whole. + r.next.writeText("new-part\n") + assertTrue(r.due(2_000)); assertEquals("old\n", file.readText()) + r.next.appendText("new-rest\n") + r.finish(refreshing = true, nowMs = 1_000 + day) + assertEquals("new-part\nnew-rest\n", file.readText()); assertFalse(r.next.exists()); assertFalse(r.due(1_000 + day + 60_000)) + } + + // Ident sounds (7 November 2026): a channel's sting plays at most once every 20 minutes; each channel counts on its own. + @Test fun identSoundsKeepTheirDistance() { + val t = 5_000_000_000L + assertTrue(IdentStings.take(9001, t)); assertFalse(IdentStings.take(9001, t + 60_000)); assertTrue(IdentStings.take(9002, t + 60_000)) + assertTrue(IdentStings.take(9001, t + IdentStings.GAP_MS)) + } +} diff --git a/android/app/src/test/java/tv/orbit/classic/SmartSetupRegressionTest.kt b/android/app/src/test/java/tv/orbit/classic/SmartSetupRegressionTest.kt index 5dca249..af9dc46 100644 --- a/android/app/src/test/java/tv/orbit/classic/SmartSetupRegressionTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/SmartSetupRegressionTest.kt @@ -67,9 +67,12 @@ class SmartSetupRegressionTest { assertTrue(lateBySeries.toString(), lateBySeries.values.max() <= 15) // Every title gets airtime in the week, but for the evening's: those take turns, at most twelve a week (7 November 2026). val evening = r.rows.filter { r.shape.slots[it.id] == Slot.Evening } + val late = r.rows.filter { r.shape.slots[it.id] == Slot.Late } val aired = r.plan.entries.filter { it.startMs < now + week }.map { it.seriesId }.toSet() - assertTrue(r.rows.filter { it !in evening }.all { it.id in aired }) + assertTrue(r.rows.filter { it !in evening && it !in late }.all { it.id in aired }) assertTrue(evening.count { it.id in aired } <= EVENING_ROTATION) + // Late takes turns too, at most LATE_ROTATION a week; when it has fewer, every one airs. + if (late.size <= LATE_ROTATION) assertTrue(late.all { it.id in aired }) else assertTrue(late.count { it.id in aired } <= LATE_ROTATION) } @Test fun ratingsFromEveryCountryAndUnknownIsNeverKids() { diff --git a/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt b/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt index 26acf79..f26e06c 100644 --- a/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt +++ b/android/app/src/test/java/tv/orbit/classic/SmartSetupTest.kt @@ -139,5 +139,11 @@ class SmartSetupTest { val monday = java.time.ZonedDateTime.of(2026, 11, 9, 0, 30, 0, 0, java.time.ZoneId.of("Europe/London")).toInstant().toEpochMilli() val sunday = monday - 60 * 60_000L assertEquals(rotationWeek(sunday, java.time.ZoneId.of("Europe/London")) + 1, rotationWeek(monday, java.time.ZoneId.of("Europe/London"))) + // Late nights take turns too, LATE_ROTATION at a time; Late's own titles never leave Late. + val lateShows = (1..52).map { "Late %02d".format(it) } + val lateParts = shapedParts(lateShows.associateWith { Slot.Late } + ("Bluey" to Slot.Daytime), eveningPage = 1) + val lateTitles = lateParts.first { it.fromHour == Slot.Late.fromHour }.titles + assertTrue(lateTitles.size <= LATE_ROTATION); assertTrue(lateTitles.all { it in lateShows }) + assertEquals(lateShows.toSet(), (0L until 4L).flatMap { eveningRotation(lateShows, it, LATE_ROTATION) }.toSet()) } } diff --git a/design/channels/sound/build-ident-stings.py b/design/channels/sound/build-ident-stings.py new file mode 100644 index 0000000..adc2dff --- /dev/null +++ b/design/channels/sound/build-ident-stings.py @@ -0,0 +1,195 @@ +"""Sound for the idents (Paul, 7 November 2026): a 7-second sting for each Blender ident, synthesised here from scratch. + +Pure Python (no samples, nothing licensed): additive bells and pads, Karplus-Strong plucks, filtered noise, a small Schroeder +reverb. Writes WAVs to design/channels/sound/out; mux-stings.sh joins each to its ident and levels it to -20 LUFS. +Run: python3 design/channels/sound/build-ident-stings.py +""" +import math, random, struct, wave +from pathlib import Path + +SR = 44100 +LEN = 7.0 +OUT = Path(__file__).parent / "out" +N = int(SR * LEN) + + +def blank(): + return [0.0] * N + + +def note(name): + names = {"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11} + pitch, octave = name[:-1], int(name[-1]) + return 440.0 * 2 ** ((names[pitch] + 12 * (octave + 1) - 69) / 12) + + +def add_bell(buf, t0, freq, amp, decay=1.6, partials=((1, 1), (2.76, .45), (5.4, .22), (8.93, .1))): + s0 = int(t0 * SR) + for ratio, a in partials: + f = freq * ratio + if f > SR / 2.2: + continue + d = decay / (ratio ** .5) + for i in range(0, min(N - s0, int(d * 6 * SR))): + t = i / SR + env = (min(1.0, t / .004)) * math.exp(-t / d) + buf[s0 + i] += amp * a * env * math.sin(2 * math.pi * f * t) + + +def add_pad(buf, t0, t1, freqs, amp, attack=1.2, release=1.4, harmonics=(1, .35, .15), detune=.003): + s0, s1 = int(t0 * SR), min(N, int((t1 + release) * SR)) + for f0 in freqs: + for sign in (-1, 1): + f = f0 * (1 + sign * detune) + ph = random.random() * 6.28 + for i in range(s0, s1): + t = (i - s0) / SR + env = min(1.0, t / attack) * (1.0 if i < t1 * SR else max(0.0, 1 - (i - t1 * SR) / (release * SR))) + v = sum(h * math.sin(2 * math.pi * f * (k + 1) * t + ph) for k, h in enumerate(harmonics)) + buf[i] += amp * env * v / len(freqs) / 2 + + +def add_pluck(buf, t0, freq, amp, decay=.996, bright=.5): + """Karplus-Strong: a short burst of noise fed round a delay line, losing a little each pass.""" + period = int(SR / freq) + line = [random.uniform(-1, 1) for _ in range(period)] + s0 = int(t0 * SR); last = 0.0 + for i in range(min(N - s0, int(2.8 * SR))): + k = i % period + cur = line[k] + nxt = decay * (bright * cur + (1 - bright) * last) + last = cur; line[k] = nxt + fade = min(1.0, i / 30) + buf[s0 + i] += amp * cur * fade + + +def add_noise(buf, t0, t1, amp, colour=.92, tremolo=0.0, shape=None): + """Noise high-passed by a one-pole filter ([colour] near 1 keeps only the top), with an optional shimmer tremolo.""" + s0, s1 = int(t0 * SR), min(N, int(t1 * SR)); prev = 0.0; lp = 0.0 + for i in range(s0, s1): + x = random.uniform(-1, 1) + lp = colour * lp + (1 - colour) * x + hp = x - lp + t = (i - s0) / SR; span = (s1 - s0) / SR + env = shape(t, span) if shape else math.sin(math.pi * t / span) + trem = 1 - tremolo * (.5 + .5 * math.sin(2 * math.pi * 9 * t)) if tremolo else 1 + buf[i] += amp * env * trem * hp + + +def add_sleigh(buf, t0, t1, amp, rate=34): + """Sleigh bells: many tiny high metallic hits, shaken in pulses, swelling in and out.""" + t = t0; span = t1 - t0 + while t < t1: + shake = .5 + .5 * math.sin(2 * math.pi * 4.5 * (t - t0)) + env = math.sin(math.pi * (t - t0) / span) + f = random.uniform(2600, 5200) + add_bell(buf, t, f, amp * env * (.35 + .65 * shake) * random.uniform(.5, 1), .045, ((1, 1), (1.53, .5), (2.41, .25))) + t += random.expovariate(rate) + + +def add_thump(buf, t0, amp, f0=110, f1=45, d=.35): + s0 = int(t0 * SR); ph = 0.0 + for i in range(min(N - s0, int(d * 4 * SR))): + t = i / SR + f = f1 + (f0 - f1) * math.exp(-t / .05) + ph += 2 * math.pi * f / SR + buf[s0 + i] += amp * math.exp(-t / d) * math.sin(ph) + + +def add_clap(buf, t0, amp): + for k, off in enumerate((0, .011, .023)): + add_noise(buf, t0 + off, t0 + off + .12, amp * (1 - k * .2), .6, shape=lambda t, s: math.exp(-t / .025)) + + +def reverb(buf, wet=.28, size=1.0): + """A small Schroeder reverb: four combs in parallel, two allpasses in series.""" + out = [0.0] * N + for d, g in ((1557, .84), (1617, .83), (1491, .85), (1422, .86)): + d = int(d * size); line = [0.0] * d + for i in range(N): + y = line[i % d]; line[i % d] = buf[i] + g * y; out[i] += y * .25 + for d, g in ((225, .5), (556, .5)): + line = [0.0] * d + for i in range(N): + y = line[i % d]; x = out[i] + g * y; line[i % d] = x; out[i] = y - g * x + return [b * (1 - wet) + o * wet for b, o in zip(buf, out)] + + +def fade_out(buf, start=6.2): + s = int(start * SR) + for i in range(s, N): + buf[i] *= max(0.0, 1 - (i - s) / (N - s)) + return buf + + +def write(name, left, right=None): + right = right or left + peak = max(max(abs(x) for x in left), max(abs(x) for x in right), 1e-9) + g = .89 / peak + OUT.mkdir(exist_ok=True) + with wave.open(str(OUT / f"{name}.wav"), "wb") as w: + w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR) + w.writeframes(b"".join(struct.pack("