Four in one: late rotation, daily collections, Family on the phone, sound for four idents

Ported from release-0.108 (0.108.99), with the sting synthesiser and its WAVs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-07 21:14:42 +00:00
co-authored by Claude Opus 5.5
parent f412089a59
commit de99ca34e6
20 changed files with 352 additions and 17 deletions
@@ -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 += `<div class="label">Who's watching</div><div style="display:flex;gap:18px;align-items:flex-start">${pr.profiles.map((p, i) => `<button data-profile="${esc(p.id)}" style="display:flex;flex-direction:column;align-items:center;gap:6px">
if (pr) h += `<div class="label">Who's watching</div><div style="display:flex;gap:16px 18px;align-items:flex-start;flex-wrap:wrap">${pr.profiles.map((p, i) => `<button data-profile="${esc(p.id)}" style="display:flex;flex-direction:column;align-items:center;gap:6px">
<span style="width:54px;height:54px;border-radius:50%;background:${colours[i % colours.length]};display:flex;align-items:center;justify-content:center;font-weight:800;font-size:20px;color:#071632;${pr.current?.id === p.id ? 'outline:3px solid var(--accent);outline-offset:3px' : ''}">${esc(p.name.charAt(0))}</span>
<span class="small" style="${pr.current?.id === p.id ? 'color:var(--ink);font-weight:700' : ''}">${esc(p.name)}</span></button>`).join('')}
${pr.pinSet ? '<span class="small" style="margin-left:auto;align-self:center">Adult needs the PIN</span>' : ''}</div>`;
${kids ? '' : `<button data-paddnew="1" aria-label="Add someone" style="display:flex;flex-direction:column;align-items:center;gap:6px"><span style="width:54px;height:54px;border-radius:50%;border:2px dashed var(--accent);display:flex;align-items:center;justify-content:center;font-size:24px;color:var(--accent)">+</span><span class="small">Add</span></button>`}
</div><div style="display:flex;justify-content:space-between;margin-top:8px">${kids ? '<span></span>' : '<a href="#" data-pedit="1" class="small" style="color:var(--accent)">Edit profiles</a>'}
${pr.pinSet ? '<span class="small">Adult needs the PIN</span>' : ''}</div>`;
if (kids) return h + `<div class="card small" style="margin-top:14px">Kids is watching. Making channels and the Director are for Adult.</div>`;
const waiting = state.directorCount;
h += `<button class="ch" data-director="1" style="margin-top:16px;background:var(--raised)"><div class="grow"><div class="what">Director's Chair</div><div class="then">${waiting ? 'Ideas and titles waiting for you' : 'Suggestions for your channels'}</div></div>
@@ -454,6 +456,22 @@ function doneView() {
<button class="btn" data-cdone="1" style="background:none;color:var(--muted)">Done</button></div>`;
}
function profileAddSheet() {
const a = state.padd || {kind: 'Grown'};
const choice = (k, title, line) => `<button class="ch ${a.kind === k ? 'on' : ''}" data-pkind="${k}"><div class="grow"><div class="what">${title}</div><div class="then" style="white-space:normal">${line}</div></div></button>`;
return `<div class="title">Add someone</div><div class="small" style="margin:6px 0 12px">They get their own Continue watching and picks. You can change anything later in Settings › Family.</div>
<input id="pname" maxlength="20" value="${esc(a.name || '')}" placeholder="Their name" style="width:100%;padding:13px;border-radius:12px;border:2px solid var(--accent);background:var(--surface);color:var(--ink);font:inherit;margin-bottom:12px">
${choice('Grown', 'Adult', 'Everything, with After Dark behind the PIN')}${choice('Kids', 'Kids', "Only Orbit's kids channels to start, never After Dark")}
<button class="btn gold" data-paddgo="1" style="margin-top:6px">Add them</button>`;
}
function profileEditSheet() {
const ps = state.profiles?.profiles || [], grown = ps.filter(p => p.kind === 'Grown').length;
return `<div class="title">Profiles</div><div class="small" style="margin:6px 0 12px">Rename or remove. Pictures and Kids' channels are in Settings › Family on the TV.</div>` +
ps.map(p => `<div class="card" style="margin-bottom:10px"><div class="small">${p.kind === 'Kids' ? 'Kids' : 'Adult'}</div>
<div style="display:flex;gap:8px;margin-top:6px"><input id="pn-${esc(p.id)}" maxlength="20" value="${esc(p.name)}" style="flex:1;min-width:0;padding:10px;border-radius:10px;border:0;background:var(--deep);color:var(--ink);font:inherit">
<button class="btn" data-prename="${esc(p.id)}" style="width:auto">Rename</button></div>
${p.kind === 'Grown' && grown === 1 ? '<div class="small" style="margin-top:6px">The only Adult stays.</div>' : `<a href="#" data-premove="${esc(p.id)}" class="small" style="color:var(--accent);display:inline-block;margin-top:8px">Remove</a>`}</div>`).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(`<div class="title">Remove ${esc(p?.name || '')}?</div><div class="small" style="margin:8px 0 14px">Their Continue watching and picks go too. Channels and schedules stay.</div>
<button class="btn gold" data-premoveyes="${esc(d.premove)}">Remove</button><button class="btn" data-pedit="1" style="margin-top:8px">Keep them</button>`); 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;
@@ -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")
}
/**
@@ -48,3 +48,25 @@ internal fun evidenceMissing(file: File, ids: List<String>): List<String> {
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
@@ -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<Int, Long>()
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
}
}
@@ -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()
@@ -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.") {
@@ -236,6 +236,8 @@ internal fun shapedParts(slots: Map<String, Slot>, 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<String, Slot>, 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<String>, page: Long): List<String> {
if (titles.size <= EVENING_ROTATION) return titles
internal fun eveningRotation(titles: List<String>, page: Long, cap: Int = EVENING_ROTATION): List<String> {
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()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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))
}
}
@@ -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() {
@@ -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())
}
}
+195
View File
@@ -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("<hh", int(l * g * 32767), int(r * g * 32767)) for l, r in zip(left, right)))
def mistletoe():
"""Sleigh-bell shimmer swelling in, a glockenspiel arpeggio as the wreath lights, a warm C major 9 bloom."""
random.seed(25)
b = blank()
add_sleigh(b, .1, 2.7, .09)
add_sleigh(b, 4.0, 6.8, .04, 22)
for k, n in enumerate(["G5", "C6", "E6", "G6", "C7"]):
add_bell(b, 1.6 + k * .14, note(n), .32 - k * .03)
add_bell(b, 3.1, note("E7"), .12, 2.4)
add_pad(b, 1.4, 5.6, [note(n) for n in ("C3", "G3", "E4", "B4", "D5")], .5, attack=1.0)
l = fade_out(reverb(b, .32, 1.1)); random.seed(26)
return l
def sakura():
"""Koto plucks rolling down an in scale as the petals fall, a soft bell when the logo settles, a hushed breath pad."""
random.seed(7)
b = blank()
add_noise(b, .0, 3.0, .012, .97)
for k, n in enumerate(["E5", "C5", "B4", "A4", "F4", "E4"]):
add_pluck(b, .9 + k * .19, note(n), .55 - k * .04, .9965, .55)
add_pluck(b, 2.6, note("A3"), .45, .997, .45)
add_bell(b, 3.05, note("A5"), .26, 2.2, ((1, 1), (3.01, .2), (6.2, .06)))
add_bell(b, 3.05, note("E6"), .12, 2.0, ((1, 1), (3.01, .2)))
add_pad(b, 2.4, 5.8, [note(n) for n in ("A3", "E4", "B4", "C5")], .32, attack=1.4, harmonics=(1, .12))
return fade_out(reverb(b, .3, 1.2))
def signature():
"""Prestige: a low felt-piano Db major 9, strings swelling up underneath, a bright ting as the marquee lights."""
random.seed(109)
b = blank()
for k, n in enumerate(["Db2", "Ab2", "F3", "C4", "Eb4"]):
add_bell(b, .5 + k * .05, note(n), .3, 2.8, ((1, 1), (2, .35), (3, .14), (4.01, .06)))
add_pad(b, .6, 5.4, [note(n) for n in ("Db3", "Ab3", "F4", "C5", "Eb5")], .55, attack=2.2, harmonics=(1, .5, .3, .2, .12))
add_bell(b, 2.9, note("Ab6"), .22, 1.8)
add_bell(b, 2.9, note("Db7"), .12, 1.6)
add_noise(b, 2.3, 2.95, .012, .98)
return fade_out(reverb(b, .3, 1.3))
def unscripted():
"""Real life, upbeat: a thump and two claps, a bouncy plucked motif, a bright stab as the logo lands."""
random.seed(110)
b = blank()
beat = .32
for k in range(8):
t = .3 + k * beat
if k % 2 == 0: add_thump(b, t, .55)
else: add_clap(b, t, .35)
for k, n in enumerate(["C5", "E5", "G5", "C5", "A5", "G5", "E5", "D5"]):
add_pluck(b, .3 + k * beat + .16, note(n), .32, .993, .7)
for n in ("C4", "E4", "G4", "C5"):
add_bell(b, 2.9, note(n), .16, .9, ((1, 1), (2, .5), (3, .3), (4, .2), (5, .1)))
add_thump(b, 2.9, .6, 90, 40, .5)
return fade_out(reverb(b, .2, .9))
if __name__ == "__main__":
for name, make in (("mistletoe", mistletoe), ("sakura", sakura), ("signature", signature), ("unscripted", unscripted)):
write(name, make()); print("wrote", name)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.