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:
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user