ROUTING learns stems the template does not name from the set (routing-learned.json); amber ones are fixed by hold/auto-fix from what the name says and earlier sets; START routes learned stems too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 12:49:03 -04:00
co-authored by Claude Opus 5.5
parent 9551e043a7
commit a4d5fbe1aa
4 changed files with 112 additions and 8 deletions
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
*.pyc *.pyc
.DS_Store .DS_Store
dist/ dist/
routing-learned.json
+1 -1
View File
@@ -155,7 +155,7 @@ everything on the page at once.
| LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens around it (less than half a 16th note either way) for the strongest hit. **3 CUES OFF** (amber): a strong hit more than 25 ms before or after the cue while the cue itself sits on a quiet spot; the details say `JEHOVAH bar 329.2.1: Blinder Flash 2 is 37 ms before the hit` (bars as Live shows them, 4/4). Listened to once per saved set. | looks again (move the note in Live by hand: Live's control script cannot edit notes in the arrangement) | | LIGHT TIMING | every blinder flash and FX cue (notes 30-39 on `LIGHTING CUES`: Blinder Flash 1-3, Blinder FX 1-5, named from `Ableton/LIGHTING CUES MIDI`) lands on the hit in the `Drums (Live)` stem under it, even when that clip is deactivated because the drummer plays live. Each cue's moment in the stem comes from the tempo map; the Tracks Mac listens around it (less than half a 16th note either way) for the strongest hit. **3 CUES OFF** (amber): a strong hit more than 25 ms before or after the cue while the cue itself sits on a quiet spot; the details say `JEHOVAH bar 329.2.1: Blinder Flash 2 is 37 ms before the hit` (bars as Live shows them, 4/4). Listened to once per saved set. | looks again (move the note in Live by hand: Live's control script cannot edit notes in the arrangement) |
| SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. Finished Songs having the song only in another key is fine (START transposes it). A song that is not in Finished Songs at all is only noted. | looks again | | SONG KEYS | every song is in the key PCO gives it: the set's locator for it (`3- JEHOVAH - B`), the pad clip under it on `AMBIENT PADS` (`B - WARM - CHURCHFRONT PADS`) and a `Finished Songs` / `Finished Custom Songs` folder (`JEHOVAH [B]`). Eb and D# are the same key. **JEHOVAH B NOT Db** (red): the locator is in another key. **JEHOVAH PAD D NOT B** (red): the wrong pad. Finished Songs having the song only in another key is fine (START transposes it). A song that is not in Finished Songs at all is only noted. | looks again |
| W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) | | W TUNE | the `WAVES TUNE` clip that starts each song (a clip from `Ableton/Waves Tune - midi Program Change`, sent to Waves Tune as a program change) is the song's PCO key; a minor song is fine on its relative major. **JEHOVAH TUNE Db** names the song; the details say which clip to put at which bar. **TUNE PGM**: a renamed clip that sends another key's program. | looks again (Live's control script cannot change a clip in the arrangement: swap the clip by hand) |
| ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; **NEW STEM BANJO** (amber): a stem name the table does not know. | puts every stem back where the table says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. | | ROUTING | read **live** from Ableton (AbletonOSC, so tracks just dragged in count before the set is saved): every stem goes where `ready.routing` says (Piano, Keys, Organ → `Ext. Out 5/6 Keys`; AG, EG, Bass → `7/8 GTRS`; Drums, Perc, FX, Synth Bass → `9/10 PERC / FX`; BGVS, Choir, Vox FX → `11/12 VOX`). The table was taken from 261025 NA Project and is edited on the settings page; "EG 13" goes where every other EG goes. **PIANO TO MASTER** (red) names the stem; A stem the table does not name is judged by what its name says it is (Synth, Arps, Bells → keys; Banjo, Mandolin → guitars; Loop, Shaker, Tambourine → perc / FX; Vox, Choir → vox) and by where earlier sets had it (`routing-learned.json` beside the code, learned from every set as it is routed): **BANJO TO 5/6 KEYS** (amber) when it is somewhere else. A name that says nothing goes where earlier sets had it, and the first time is learned as routed. | puts every stem back where the table, or for amber its name and earlier sets, says (and IN EARS' sends to 0 dB). Runs by itself in the auto-fix hours. |
| IN EARS | read live: no stem in a song group has its in-ear send (send A, to `A-IN EAR MIX`) at -inf, as songs dragged in from another set do. Levels someone set are left alone; `RESI TRACKS` and `MIDI GROUP` (`ready.ears_skip_groups`) are skipped. | sets those sends to 0 dB. Runs by itself in the auto-fix hours. | | IN EARS | read live: no stem in a song group has its in-ear send (send A, to `A-IN EAR MIX`) at -inf, as songs dragged in from another set do. Levels someone set are left alone; `RESI TRACKS` and `MIDI GROUP` (`ready.ears_skip_groups`) are skipped. | sets those sends to 0 dB. Runs by itself in the auto-fix hours. |
| MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped | | MIDI | the Tracks Mac's MIDI links (FX LIGHTS, TRK LIVEBOX, TRK STUDIO B) and Ableton running | reconnects the MIDI link that dropped |
| PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again |
+66 -6
View File
@@ -819,6 +819,47 @@ def template_for(routing, name):
return list(fam.pop()) if len(fam) == 1 else None return list(fam.pop()) if len(fam) == 1 else None
# Stems the routing template does not name are learned from the set, as routed there (user, 2026-10-27: "where it's
# routed right now is correct ... build your knowledge"). Kept beside the code, not in config.json: the settings'
# template still wins, and a learned route is only ever asked about, never changed by a hold.
LEARNED_ROUTING = os.path.join(os.path.dirname(os.path.abspath(__file__)), "routing-learned.json")
def learned_routing():
try:
with open(LEARNED_ROUTING) as f:
return json.load(f)
except (OSError, ValueError):
return {}
def save_learned_routing(learned):
tmp = LEARNED_ROUTING + ".tmp"
with open(tmp, "w") as f:
json.dump(learned, f, indent=1, sort_keys=True)
os.replace(tmp, LEARNED_ROUTING)
# What a stem is, from its name (the workflow's routing table: keys sounds 5/6, guitars and regular bass 7/8, drums,
# percussion, aux drums, sub bass and FX 9/10, vocal effects, BGVs and choir 11/12), in this order: "VOX FX" is
# vocals, "SYNTH BASS" the low end, "SYNTH LOOP" keys, a bare "LOOP" drums. The output comes from the template's own
# entry for that kind of stem.
KINDS = (("BGVS", r"\b(VOX|VOCALS?|BGVS?|CHOIR|VOICES?|HARMON(Y|IES)|SINGERS?)\b"),
("SYNTH BASS", r"\b(SUB|SYNTH BASS|808)\b"),
("PIANO", r"\b(KEYS?|PIANO|SYNTHS?|ORGAN|PADS?|STRINGS?|ARPS?|BELLS?|RHODES|WURLI|PLUCKS?|CELLO|VIOLINS?|BRASS|HORNS?|ORCH\w*|CELESTE|GLOCK\w*)\b"),
("EG 1", r"\b(GTRS?|GUITARS?|AG|EG|ACOUSTIC|ELECTRIC|MANDOLIN|BANJO|BASS)\b"),
("PERC", r"\b(DRUMS?|PERC\w*|SHAKERS?|TAMB\w*|CYMBALS?|KICK|SNARE|HATS?|CLAPS?|LOOPS?|HITS?|IMPACTS?|RISERS?|FX|TOMS|TIMPANI)\b"))
def stem_kind(table, name):
"""Where a stem's kind goes ([type, channel]), by its name; None when the name says nothing."""
n = stem_name(name)
for rep_, pattern in KINDS:
if re.search(pattern, n) and rep_ in table:
return list(table[rep_])
return None
def mix_plan(rc, live): def mix_plan(rc, live):
"""(routing Line, ears Line, changes): what is routed away from the template, which song stems have """(routing Line, ears Line, changes): what is routed away from the template, which song stems have
their in-ear send at -inf, and the changes that put both right.""" their in-ear send at -inf, and the changes that put both right."""
@@ -828,8 +869,10 @@ def mix_plan(rc, live):
line.fault(WARN, live["error"], "%s: AbletonOSC on the Tracks Mac did not answer" % live["error"]) line.fault(WARN, live["error"], "%s: AbletonOSC on the Tracks Mac did not answer" % live["error"])
return routing, ears, [] return routing, ears, []
table = {stem_name(k): v for k, v in (rc.get("routing") or {}).items()} table = {stem_name(k): v for k, v in (rc.get("routing") or {}).items()}
learned = learned_routing()
learned_before = dict(learned)
skip = {norm(g) for g in rc.get("ears_skip_groups", [])} skip = {norm(g) for g in rc.get("ears_skip_groups", [])}
changes, top, n_route, n_ears, new = {}, None, 0, 0, [] changes, top, n_route, n_ears = {}, None, 0, 0
for t in live.get("tracks") or []: for t in live.get("tracks") or []:
if t["group"] and not t["grouped"]: if t["group"] and not t["grouped"]:
top = t["name"] top = t["name"]
@@ -847,16 +890,33 @@ def mix_plan(rc, live):
elif want: elif want:
n_route += 1 n_route += 1
elif table and top and norm(top) not in skip: elif table and top and norm(top) not in skip:
new.append(t["name"]) # Not in the template: what earlier sets taught (learned) and what its name says (kind) decide;
# when they disagree the name wins, and a set that agrees with the name teaches it.
cur = [t["type"], t["channel"] or ""]
had, kind = template_for(learned, t["name"]), stem_kind(table, t["name"])
fix = kind if kind and cur != kind and had != cur else (had if had and not kind and cur != list(had) else None)
if fix:
why = ("its name says %s" % stem_name(t["name"]).split()[0]) if fix == kind else "earlier sets had it there"
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]}).update(type=fix[0], channel=fix[1])
routing.fault(WARN, "%s TO %s" % (short(stem_name(t["name"]), 12), (t["channel"] or t["type"]).upper()[:10]),
"%s (%s) goes to %s %s; it belongs at %s %s (%s): a hold moves it" % (
t["name"], top, t["type"], t["channel"], fix[0], fix[1], why))
else:
if had is None or list(had) != cur:
learned[stem_name(t["name"])] = cur
routing.notes.append("learned: %s goes to %s %s (%s)" % (stem_name(t["name"]), t["type"], t["channel"], top))
n_route += 1
if top and norm(top) not in skip and t.get("send") is not None: if top and norm(top) not in skip and t.get("send") is not None:
if t["send"] <= 0.0: if t["send"] <= 0.0:
changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]})["send"] = 1.0 changes.setdefault(t["index"], {"index": t["index"], "name": t["name"]})["send"] = 1.0
ears.fault(ERR, "EARS OFF " + short(top, 12), "%s in %s: send A is at -inf" % (t["name"], top)) ears.fault(ERR, "EARS OFF " + short(top, 12), "%s in %s: send A is at -inf" % (t["name"], top))
else: else:
n_ears += 1 n_ears += 1
if new: if learned != learned_before:
routing.fault(WARN, "NEW STEM " + short(stem_name(new[0]), 12), "not in the routing template (add them in the " try:
"settings): " + ", ".join(new[:12])) save_learned_routing(learned)
except OSError:
pass
routing.info = "%d ROUTED" % n_route if table else "NO TEMPLATE" routing.info = "%d ROUTED" % n_route if table else "NO TEMPLATE"
ears.info = "%d SENDS ON" % n_ears ears.info = "%d SENDS ON" % n_ears
if live.get("playing"): if live.get("playing"):
@@ -2172,7 +2232,7 @@ def start_params(rc, services, mode):
return {"mode": mode, "projects": projects, "site_folder": rc.get("site_folder", ""), return {"mode": mode, "projects": projects, "site_folder": rc.get("site_folder", ""),
"folder_format": rc.get("folder_format", "{date} {loc} Project"), "finished_folders": rc.get("finished_folders", []), "folder_format": rc.get("folder_format", "{date} {loc} Project"), "finished_folders": rc.get("finished_folders", []),
"pads_folder": rc.get("pads_folder") or os.path.join(os.path.dirname(projects.rstrip("/")), "PADS"), "pads_folder": rc.get("pads_folder") or os.path.join(os.path.dirname(projects.rstrip("/")), "PADS"),
"tune_library": rc.get("tune_library", ""), "routing": rc.get("routing") or {}, "tune_library": rc.get("tune_library", ""), "routing": dict(learned_routing(), **(rc.get("routing") or {})),
"keep_groups": rc.get("start_keep_groups") or rc.get("ears_skip_groups") or ["MIDI GROUP", "RESI TRACKS"], "keep_groups": rc.get("start_keep_groups") or rc.get("ears_skip_groups") or ["MIDI GROUP", "RESI TRACKS"],
"pad_track": rc.get("pad_track", "AMBIENT PADS"), "tune_track": rc.get("tune_track", "WAVES TUNE"), "pad_track": rc.get("pad_track", "AMBIENT PADS"), "tune_track": rc.get("tune_track", "WAVES TUNE"),
"lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"), "lighting_track": rc.get("lighting_track", "LIGHTING CUES"), "sections_track": rc.get("sections_track", "SECTIONS"),
+44 -1
View File
@@ -4,6 +4,8 @@ watchdog and the page builder show them. Run: python3 -m unittest discover -s t
import json import json
import logging import logging
import os import os
import shutil
import tempfile
import sys import sys
import unittest import unittest
from datetime import datetime from datetime import datetime
@@ -13,6 +15,7 @@ sys.path.insert(0, ROOT)
import make_page # noqa: E402 import make_page # noqa: E402
import readiness as rd # noqa: E402 import readiness as rd # noqa: E402
rd.LEARNED_ROUTING = os.path.join(tempfile.mkdtemp(prefix="sp-learned-"), "routing-learned.json") # never the repo's
import status_panel as sp # noqa: E402 import status_panel as sp # noqa: E402
sp.log.addHandler(logging.NullHandler()) sp.log.addHandler(logging.NullHandler())
@@ -362,9 +365,49 @@ def live_set(**over):
class TestAbletonLive(unittest.TestCase): class TestAbletonLive(unittest.TestCase):
def setUp(self):
d = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, d)
orig = rd.LEARNED_ROUTING
rd.LEARNED_ROUTING = os.path.join(d, "routing-learned.json")
self.addCleanup(setattr, rd, "LEARNED_ROUTING", orig)
def test_a_new_stem_is_learned_from_the_set(self):
"""User, 2026-10-27: the set's routing is right; learn the stems the template does not name."""
routing, _, changes = rd.mix_plan(RC, live_set())
self.assertNotIn("BANJO", " ".join(msgs(routing)))
self.assertIn("learned: BANJO goes to Ext. Out 7/8 GTRS (JEHOVAH)", routing.notes)
self.assertEqual(rd.learned_routing(), {"BANJO": ["Ext. Out", "7/8 GTRS"]})
self.assertEqual(rd.start_params(RC, [], "run")["routing"]["BANJO"], ["Ext. Out", "7/8 GTRS"])
# Next week the banjo lands on Keys: amber, and a hold (or auto-fix) moves it back (a banjo is a guitar).
over = live_set()
over["tracks"][-1] = dict(over["tracks"][-1], channel="5/6 Keys")
routing, _, changes = rd.mix_plan(RC, over)
self.assertIn("BANJO TO 5/6 KEYS", msgs(routing))
self.assertEqual([lv for lv, m, _ in routing.faults if m.startswith("BANJO")], [rd.WARN])
self.assertIn({"index": 16, "name": "70-Banjo [2026-10-21 132128]", "type": "Ext. Out", "channel": "7/8 GTRS"}, changes)
self.assertEqual(rd.learned_routing(), {"BANJO": ["Ext. Out", "7/8 GTRS"]})
def test_what_a_stem_is_by_its_name(self):
t = {rd.stem_name(k): v for k, v in RC["routing"].items()}
keys, gtrs, perc, vox = (list(t[k]) for k in ("PIANO", "EG 1", "PERC", "BGVS"))
for name, want in (("29-Synth FX", keys), ("30-Synth Loop", keys), ("33-Arps 1", keys), ("35-Bells", keys),
("49-Loop 1", perc), ("74-Shaker [2026-10-26 114326]", perc), ("75-Tambourine 1", perc),
("99-Drums [2026-10-26 114524]", perc), ("Sub Bass", list(t["SYNTH BASS"])), ("Mandolin", gtrs),
("Vox FX 2", vox), ("Choir 4", vox)):
self.assertEqual(rd.stem_kind(t, name), want, name)
self.assertIsNone(rd.stem_kind(t, "Click"))
# An unknown name goes where earlier sets had it; a new one that agrees with its name is learned.
rd.save_learned_routing({"WHISTLE": ["Ext. Out", "11/12 VOX"]})
over = live_set()
over["tracks"].append(dict(over["tracks"][-1], name="71-Whistle", index=17, channel="5/6 Keys"))
routing, _, changes = rd.mix_plan(RC, over)
self.assertIn("WHISTLE TO 5/6 KEYS", msgs(routing))
self.assertIn({"index": 17, "name": "71-Whistle", "type": "Ext. Out", "channel": "11/12 VOX"}, changes)
def test_routing_and_in_ears(self): def test_routing_and_in_ears(self):
routing, ears, changes = rd.mix_plan(RC, live_set()) routing, ears, changes = rd.mix_plan(RC, live_set())
self.assertEqual(msgs(routing), ["BASS TO MASTER", "NEW STEM BANJO"]) self.assertEqual(msgs(routing), ["BASS TO MASTER"])
self.assertEqual(msgs(ears), ["EARS OFF JEHOVAH"]) self.assertEqual(msgs(ears), ["EARS OFF JEHOVAH"])
self.assertIn("50-Keys 1 [2026-10-21 132128] in JEHOVAH: send A is at -inf", ears.as_dict()["details"]) self.assertIn("50-Keys 1 [2026-10-21 132128] in JEHOVAH: send A is at -inf", ears.as_dict()["details"])
# EG 13 is not in the template, but every EG goes to 7/8 GTRS; RESI and top-level tracks keep their -inf sends # EG 13 is not in the template, but every EG goes to 7/8 GTRS; RESI and top-level tracks keep their -inf sends