`).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.
+ RemoveKeep them`); 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("