fix(orchestrator): S1 fit, rename once, Deutschlandfunk e2e, squelch, tree cause

Requirement 001 phase 6, S1:

- Fit: Agents and Artifacts in a graph view fill the window from 861 px
  up (body.fit flex column); the e2e walk probes #agents/orbit at every
  width for a page no taller than the window.
- Rename once: a session seen under two names by one source (two claims)
  flipped its name on every state build, so the Stream logged a rename
  each poll. Names.observe keeps only the newest sighting per session
  and source, and the timeline emits each (session, before, after) pair
  once.
- Deutschlandfunk: root cause was the signed media-src, which never
  listed the redirect target f111.rndfnk.com that st01.sslstream.dlf.de
  answers with (fixed in ba97f7c). The e2e now replays that chain, a
  302 from one https origin to a second, in Chromium and Firefox, with
  self-signed throwaway certificates the drivers accept.
- Squelch: a settings slider 0..1 scales every tone; at 0 no beep plays,
  music on or off (Voice.beep skips a silent tone). Stored settings
  without the key load with 1.0.
- Tree: the missing tree file error names `pkgmgr branch tree` and the
  release and install step that makes the timer write it.

Evidence: make test 655 OK, ruff and markdownlint clean
(/tmp/s1-make-test-1.log); e2e 17 OK, none skipped (/tmp/s1-e2e-3.log);
MEDUSA 14 files, 0 findings.

🎯 Fix: 94 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 90
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-08 13:37:02 +00:00
1 parent 86b55dbc91
commit a388c6ef77
15 files changed
+148 -26

No files matched your search

+5 -5
View File
@@ -581,16 +581,16 @@ flowchart TD
### Phase 6: fixes (S1)
- [ ] `#agents/orbit` at 1920, 1366 and 390 px has
- [x] `#agents/orbit` at 1920, 1366 and 390 px has
`document.documentElement.scrollHeight <= innerHeight` (e2e probe).
- [ ] Two state builds with the same claims add no second rename event to
- [x] Two state builds with the same claims add no second rename event to
the Stream (unit test on the timeline).
- [ ] The Deutschlandfunk AAC stream plays in Chromium and Firefox through
- [x] The Deutschlandfunk AAC stream plays in Chromium and Firefox through
the radio (e2e with a recorded fixture of its redirect chain) and the
root cause is named in the commit message.
- [ ] With squelch at 0 no beep plays, music on or off (unit test on the
- [x] With squelch at 0 no beep plays, music on or off (unit test on the
audio scheduler).
- [ ] Without a tree file the Tree names the missing `pkgmgr branch tree`
- [x] Without a tree file the Tree names the missing `pkgmgr branch tree`
and the release step.
### Phase 6: popups and search (S2)
+18 -2
View File
@@ -65,9 +65,14 @@ class Directory:
def renames(self, begin: float, end: float) -> list[dict]:
found = []
shown = set()
for session, own in self.by_session.items():
for before, after in itertools.pairwise(own):
if before["name"] != after["name"] and begin <= after["first_seen"] <= end:
pair = (session, before["name"], after["name"])
if before["name"] == after["name"] or pair in shown:
continue
shown.add(pair)
if begin <= after["first_seen"] <= end:
found.append(event({
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
@@ -89,9 +94,20 @@ class Names:
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
"""Args:
sightings: (session, name, seen, source); of several sightings of
one session from one source only the newest counts, so two
claims under different names cannot flip the name back and
forth on every state build.
"""
newest: dict[tuple[str, str], tuple[str, str, float | None, str]] = {}
for sighting in sightings:
key = (sighting[0], sighting[3])
if key not in newest or (sighting[2] or -math.inf) >= (newest[key][2] or -math.inf):
newest[key] = sighting
with self.lock:
changed = False
for session, name, seen, source in sightings:
for session, name, seen, source in newest.values():
key = f"{session}\x00{source}"
if not session or not name or self.observed.get(key) == name:
continue
@@ -36,6 +36,7 @@ SETTINGS = {
"direct": {"on": True, "volume": 0.5},
"radio": {"on": False, "volume": 0.25},
},
"squelch": 1.0,
"notify": True,
"streams": [],
"speech": {"on": False, "classes": ["mayday", "pan-pan", "security"], "muted": []},
@@ -195,6 +196,7 @@ def valid_settings(raw: object) -> dict:
"colors": {key: color(colors[key], f"colors.{key}") for key in SETTINGS["colors"]},
"music": {"on": flag(music["on"], "music.on"), "volume": number_in(music["volume"], "music.volume", 0.0, 1.0)},
"sounds": {kind_name: tone_setting(sounds[kind_name], kind_name) for kind_name in TONES},
"squelch": number_in(data["squelch"], "squelch", 0.0, 1.0),
"notify": flag(data["notify"], "notify"),
"streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])],
"speech": speech_setting(data["speech"]),
+4 -1
View File
@@ -191,7 +191,10 @@ class Tree:
try:
info = self.file.stat()
except OSError as error:
return {"repos": [], "scanned": None, "error": f"no pkgmgr tree file at {self.file}: {error.strerror}"}
return {"repos": [], "scanned": None, "error": (
f"no pkgmgr tree file at {self.file}: {error.strerror}. The tree timer runs `pkgmgr branch tree`, which the installed pkgmgr "
"lacks until a release ships it: release pkgmgr and install it, then the timer writes the file within a minute."
)}
stamp = (info.st_mtime_ns, info.st_size)
if self.cached is None or stamp != self.stamp:
self.cached, self.stamp = read_tree(self.file), stamp
+8
View File
@@ -209,6 +209,14 @@ table.diff tr.more td { padding: 8px 16px; color: var(--muted); font-style: ital
.graph-body { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; }
.stage-card { padding: 8px; }
.stage svg { display: block; width: 100%; height: auto; max-height: calc(100vh - var(--head) - 120px); }
@media (min-width: 861px) {
body.fit { display: flex; flex-direction: column; height: 100vh; }
body.fit .shell, body.fit main, body.fit .graph, body.fit .stage-card, body.fit .stage { display: flex; flex: 1; flex-direction: column; min-height: 0; }
body.fit main { padding-bottom: var(--sp-5); }
body.fit .graph-body { flex: 1; align-items: stretch; min-height: 0; }
body.fit .stage svg, body.fit .stage canvas.space, body.fit .graph3d { flex: 1; height: auto; min-height: 0; max-height: none; }
body.fit .panel { position: static; max-height: none; min-height: 0; overflow: auto; }
}
.legend { display: flex; flex-wrap: wrap; gap: 6px 16px; padding: 6px 10px 4px; color: var(--muted); font-size: var(--fs-sm); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { display: inline-block; width: 18px; height: 4px; border-radius: 2px; background: var(--c); }
+1 -1
View File
@@ -47,7 +47,7 @@
const newRadio = state.radio.filter((row) => !known.radio.has(`${row.ts}|${row.from}|${row.text.slice(0, 40)}`));
const direct = newRadio.filter((row) => row.to.includes(HUMAN));
const present = {mayday: newAlarms.length > 0, review: fresh.some((item) => item.kind !== "operator"), direct: direct.length > 0, radio: newRadio.length > 0};
const kind = SOUND_KINDS.map(([id]) => id).find((id) => present[id] && settings.sounds[id].on);
const kind = Voice.beep(present, settings, SOUND_KINDS.map(([id]) => id));
if (kind) {
play(kind, loudness(kind));
}
+1
View File
@@ -70,6 +70,7 @@
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
document.body.classList.toggle("fit", ["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen]));
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
+3 -1
View File
@@ -111,6 +111,7 @@
const loudest = () => Math.max(...tones.map((tone) => Number(tone.level.value)), Number(volume.value));
return {id, on, level, row: h("div", {class: "sound-row"}, h("strong", {}, label), h("label", {class: "check"}, on, "an"), level, btn("Test", "btn small ghost", () => play(id, id === "mayday" ? loudest() : Number(level.value))))};
});
const squelch = h("input", {type: "range", min: 0, max: 1, step: 0.05, value: settings.squelch, "aria-label": "Squelch"});
const notify = h("input", {type: "checkbox", checked: settings.notify});
const speechOn = h("input", {type: "checkbox", checked: settings.speech.on, disabled: !state.speech.tts});
const spoken = SPEECH_CLASSES.map(([id, label]) => ({id, label, box: h("input", {type: "checkbox", checked: id === "mayday" || settings.speech.classes.includes(id), disabled: id === "mayday", title: id === "mayday" ? "MAYDAY wird immer vorgelesen" : null})}));
@@ -130,7 +131,7 @@
h("section", {}, h("h3", {}, "Musik"), h("p", {class: "muted"}, "Generative Ambient-Musik, offline erzeugt: ruhig bei leerer Inbox, etwas gespannter, wenn etwas wartet, leiser unter Meldungen."),
h("div", {class: "grid"}, h("label", {class: "check"}, musicOn, "Musik an"), h("label", {}, "Gesamtlautstärke (Musik und Radio)", volume))),
h("section", {}, h("h3", {}, "Töne und Benachrichtigungen"), h("p", {class: "muted"}, "MAYDAY und PAN-PAN sind immer am lautesten. Bei mehreren neuen Ereignissen spielt nur das dringendste."),
tones.map((tone) => tone.row), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
tones.map((tone) => tone.row), h("label", {}, "Squelch: scales every tone, 0 plays none", squelch), h("label", {class: "check"}, notify, "Desktop-Benachrichtigungen zeigen, wenn der Browser sie erlaubt")),
h("section", {}, h("h3", {}, "Funk vorlesen"),
h("p", {class: "muted"}, state.speech.tts
? "Neuer Funk wird in Seefunk-Sprechweise (SMCP) vorgelesen, eine Meldung nach der anderen, jede Station mit eigener Stimme; Musik und Radio werden solange leiser. Die Lautstärke folgt den Tönen oben, MAYDAY wird immer und am lautesten gesprochen."
@@ -152,6 +153,7 @@
colors: Object.fromEntries(COLOR_FIELDS.map(([id]) => [id, colors[id].value])),
music: {on: musicOn.checked, volume: Number(volume.value)},
sounds: Object.fromEntries(tones.map((tone) => [tone.id, {on: tone.on.checked, volume: Number(tone.level.value)}])),
squelch: Number(squelch.value),
notify: notify.checked,
streams: rows.map((entry) => entry.read()),
speech: {
+7 -5
View File
@@ -12,10 +12,12 @@ const Voice = (() => {
}
function loudness(tone, settings) {
if (tone !== "mayday") {
return settings.sounds[tone].volume;
}
return Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume);
const level = tone === "mayday" ? Math.max(...Object.values(settings.sounds).map((sound) => sound.volume), settings.music.volume) : settings.sounds[tone].volume;
return level * settings.squelch;
}
function beep(present, settings, kinds) {
return kinds.find((kind) => present[kind] && settings.sounds[kind].on && loudness(kind, settings) > 0) || null;
}
function speakable(row, speech) {
@@ -64,7 +66,7 @@ const Voice = (() => {
return buffer;
}
return {toneOf, loudness, speakable, enqueue, frames, wav};
return {toneOf, loudness, beep, speakable, enqueue, frames, wav};
})();
if (typeof module === "object" && module.exports) {
module.exports = Voice;
+1 -1
View File
@@ -66,7 +66,7 @@ function send(method, params) {
const results = [];
try {
await send("session.new", {capabilities: {}});
await send("session.new", {capabilities: {alwaysMatch: {acceptInsecureCerts: true}}});
await send("session.subscribe", {events: ["log.entryAdded"]});
if (plan.init) {
await send("script.addPreloadScript", {functionDeclaration: plan.init});
+2 -1
View File
@@ -13,7 +13,8 @@ const plan = JSON.parse(readFileSync(planFile, "utf8"));
const profile = mkdtempSync(join(tmpdir(), "cdp-"));
// --no-sandbox: the agent sandbox already forbids the user namespaces Chromium's own sandbox needs.
// --enable-unsafe-swiftshader: without a GPU, headless Chromium gives WebGL only through SwiftShader, which it no longer enables by itself.
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
// --ignore-certificate-errors: the https station fixtures use a throwaway self-signed certificate.
const flags = ["--headless", "--no-sandbox", "--disable-gpu", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-certificate-errors", "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", "--no-first-run", "--no-default-browser-check", "--hide-scrollbars", `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
const browser = spawn(binary, flags, {stdio: ["ignore", "ignore", "pipe"]});
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const KEYS = {Enter: [13, "\r"], Tab: [9, ""]};
+55 -7
View File
@@ -8,6 +8,7 @@ import json
import math
import os
import shutil
import ssl
import subprocess
import sys
import tempfile
@@ -17,6 +18,7 @@ import unittest
from collections.abc import Callable
from pathlib import Path
from unittest import mock
from urllib.parse import urlsplit
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent))
@@ -101,6 +103,7 @@ PROBE = """(async () => {@HELPERS@
flowEdges: all("#main svg.chart path.flow").map((path) => path.dataset.side),
openWork: all("#main .open-work").length,
wide: document.documentElement.scrollWidth > window.innerWidth + 1,
tall: document.documentElement.scrollHeight > window.innerHeight,
};
})()""".replace("@HELPERS@", HELPERS)
ORG_OPEN = """(async () => {@HELPERS@
@@ -351,6 +354,8 @@ ADD_STATION = """(async () => {@HELPERS@
return {toasts: all("#toasts .toast").map(text)};
})()""".replace("@HELPERS@", HELPERS)
STATION_NAME = "E2E Ton mit einem sehr langen Sendernamen, der in keine Zeile passt"
DLF_PATH = "/dlf/01/high/aac/stream.aac?aggregator=web"
DLF_NODE = "/ard/dlf/01/aac/192/stream.aac?aggregator=web"
RADIO_STATE = """(async () => {@HELPERS@@MEDIA@
await until(() => log().some((entry) => entry.play), 8000);
return {log: log(), media: await advancing(), toasts: all("#toasts .toast").map(text)};
@@ -481,6 +486,12 @@ def tone(seconds: int, rate: int) -> bytes:
class Station(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.server.moved:
self.send_response(302)
self.send_header("Location", self.server.moved)
self.send_header("Content-Length", "0")
self.end_headers()
return
body = self.server.body
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
@@ -677,15 +688,41 @@ class TestOrchestratorE2E(HubCase):
patch.start()
self.addCleanup(patch.stop)
def station(self) -> str:
def station(self, tls: ssl.SSLContext | None = None, moved: str | None = None) -> str:
"""Args:
tls: serves https with this context instead of plain http.
moved: answers every request with a 302 to this URL instead of
the tone.
"""
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Station)
httpd.body = tone(30, 22050)
httpd.body, httpd.moved = tone(30, 22050), moved
if tls:
httpd.socket = tls.wrap_socket(httpd.socket, server_side=True)
thread = threading.Thread(target=httpd.serve_forever, args=(0.05,), daemon=True)
thread.start()
self.addCleanup(httpd.server_close)
self.addCleanup(thread.join, 10)
self.addCleanup(httpd.shutdown)
return f"http://127.0.0.1:{httpd.server_address[1]}/tone.wav"
return f"{'https' if tls else 'http'}://127.0.0.1:{httpd.server_address[1]}{DLF_PATH if moved else '/tone.wav'}"
def redirected(self) -> tuple[str, list[str]]:
"""Returns:
A station that replays the recorded Deutschlandfunk chain, a 302
from st01.sslstream.dlf.de to an f111.rndfnk.com node, as a 302 to
a second https origin, and that origin as its released via.
"""
folder = Path(tempfile.mkdtemp())
self.addCleanup(shutil.rmtree, folder)
key, cert = folder / "key.pem", folder / "cert.pem"
subprocess.run([
"openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "-nodes", "-subj", "/CN=127.0.0.1",
"-addext", "subjectAltName=IP:127.0.0.1", "-days", "1", "-keyout", str(key), "-out", str(cert),
], capture_output=True, timeout=60, check=True)
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain(cert, key)
node = urlsplit(self.station(tls))
origin = f"https://{node.netloc}"
return self.station(tls, f"{origin}{DLF_NODE}"), [origin]
def check_overlaps(self, browser: str, driver: str, binary: str) -> None:
steps = []
@@ -706,20 +743,23 @@ class TestOrchestratorE2E(HubCase):
self.assertIn("Verzeichnis (Glob)", rules["review"]["labels"])
self.assertEqual(settings, {"found": [], "outside": []})
def check_radio(self, browser: str, driver: str, binary: str) -> dict:
def check_radio(self, browser: str, driver: str, binary: str, redirected: bool = False) -> dict:
"""Args:
driver: cdp.mjs saves the station through the settings form and
signs its new origin with a virtual passkey; bidi.mjs has no
virtual authenticator, so the station is saved before the page
loads.
redirected: plays the recorded Deutschlandfunk redirect chain,
saved before the page loads with its resolved via.
"""
station = self.station()
signs = driver == "cdp.mjs"
station, via = self.redirected() if redirected else (self.station(), [])
browser = f"{browser}-dlf" if redirected else browser
signs = driver == "cdp.mjs" and not redirected
if signs:
first = {"width": 1366, "height": 768, "url": f"http://localhost:{self.port}/#stream", "wait": 900, "probe": ADD_STATION.replace("@STATION@", station).replace("@NAME@", STATION_NAME), "shot": None, "gesture": True}
else:
hub = self.httpd.hub
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": []}]}, hub.settings.get())
hub.settings.put({**hub.settings.get(), "streams": [{"name": STATION_NAME, "url": station, "from": "00:00", "to": "24:00", "days": list(range(7)), "on": True, "via": via}]}, hub.settings.get())
first = {"width": 1366, "height": 768, "url": f"{self.base}#stream", "wait": 900, "probe": None, "shot": None}
steps = [
first,
@@ -761,6 +801,13 @@ class TestOrchestratorE2E(HubCase):
def test_firefox_plays_a_saved_station(self):
self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"))
def test_chromium_plays_deutschlandfunk_through_its_redirect(self):
self.check_radio("chromium", "cdp.mjs", os.environ.get("CHROMIUM", "chromium"), redirected=True)
@unittest.skipUnless(shutil.which(os.environ.get("FIREFOX", "firefox")), "no Firefox")
def test_firefox_plays_deutschlandfunk_through_its_redirect(self):
self.check_radio("firefox", "bidi.mjs", os.environ.get("FIREFOX", "firefox"), redirected=True)
def test_chromium_walks_every_screen_at_three_widths(self):
steps = [{"width": width, "height": height, "url": f"{self.base}#{screen}", "wait": 900, "probe": PROBE, "shot": self.shot(f"{width}-{screen.replace('/', '-')}")} for width, height in WIDTHS for screen in SCREENS]
steps += [{"width": 1366, "height": 768, "url": f"{self.base}#{old}", "wait": 600, "probe": PROBE, "shot": None} for old in ("graph", "graph/chord", "tree")]
@@ -786,6 +833,7 @@ class TestOrchestratorE2E(HubCase):
self.assertGreater(probe["icons"], 8)
self.assertEqual(labelled(probe), {"unlabelled": [], "broken": []})
self.assertFalse(probe["wide"], "the page scrolls sideways")
self.assertEqual([probe["tall"] for probe in probes if probe["hash"] == "#agents/orbit"], [False] * len(WIDTHS))
by_hash = {probe["hash"]: probe for probe in probes}
stream = by_hash["#stream"]
self.assertEqual([probe["chips"] for probe in probes if probe["hash"] == "#stream"], [["Für dich", "Funk", "Arbeit", "Transkripte"]] * len(WIDTHS))
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
import hub_support # noqa: F401
from kvb_orchestrator import names
class TestRenameOnce(unittest.TestCase):
def test_two_state_builds_with_the_same_claims_add_no_second_rename(self):
with tempfile.TemporaryDirectory() as folder:
book = names.Names(Path(folder) / "names.json")
claims = [("s1", "Stage", 100.0, "claim"), ("s1", "i18n 2.0", 200.0, "claim"), ("s2", "Main", 50.0, "claim")]
counts = []
for build in (1000.0, 2000.0):
book.observe(claims, build)
counts.append(len(names.Directory(book.listed()).renames(0, 3000)))
self.assertEqual(counts, [0, 0])
for build in (3000.0, 4000.0):
book.observe([*claims, ("s1", "Skills", 300.0, "title")], build)
self.assertEqual([row["title"] for row in names.Directory(book.listed()).renames(0, 5000)], ["i18n 2.0 heißt jetzt Skills"])
def test_a_name_pair_shows_once_per_session(self):
entries = [{"session": "s1", "name": name, "first_seen": ts, "source": "claim"} for name, ts in (("A", 1), ("B", 2), ("A", 3), ("B", 4))]
self.assertEqual([row["title"] for row in names.Directory(entries).renames(0, 10)], ["A heißt jetzt B", "B heißt jetzt A"])
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -52,7 +52,9 @@ class TestHubTree(HubCase):
status, _, body = self.get("/api/tree")
self.assertEqual(status, 200, body)
self.assertEqual((json.loads(body)["repos"], json.loads(body)["scanned"]), ([], None))
self.assertIn("no pkgmgr tree file", json.loads(body)["error"])
for words in ("no pkgmgr tree file", "`pkgmgr branch tree`", "release pkgmgr"):
with self.subTest(words=words):
self.assertIn(words, json.loads(body)["error"])
self.tree_file.write_text('{"repos": [{"path": "/x"}]}', encoding="utf-8")
self.assertIn("unreadable", json.loads(self.get("/api/tree")[2])["error"])
+7 -1
View File
@@ -38,6 +38,7 @@ console.log(JSON.stringify({
spoken: filters.map((filter) => rows.map((row) => V.speakable(row, filter))),
queue: pending.map((item) => item.cls),
loud: settings.map((one) => Object.fromEntries(["mayday", "review", "direct", "radio"].map((tone) => [tone, V.loudness(tone, one)]))),
beeps: settings.map((one) => V.beep({mayday: true, review: true, direct: true, radio: true}, one, ["mayday", "review", "direct", "radio"])),
frames: [V.frames(1.5, 16000, 120), V.frames(130, 16000, 120), V.frames(0, 16000, 120)],
wav: Buffer.from(V.wav(Float32Array.from([0, 0.5, -0.5, 1, -1, 2, -2]), 16000)).toString("base64"),
}));
@@ -52,6 +53,7 @@ class TestVoiceModule(unittest.TestCase):
settings.SETTINGS,
{**settings.SETTINGS, "music": {"on": True, "volume": 1.0}, "sounds": quiet},
{**settings.SETTINGS, "sounds": {**settings.SETTINGS["sounds"], "mayday": {"on": True, "volume": 0.0}, "radio": {"on": True, "volume": 0.8}}},
*({**settings.SETTINGS, "squelch": 0.0, "music": {"on": music, "volume": 1.0}, "sounds": {name: {"on": True, "volume": 1.0} for name in settings.TONES}} for music in (True, False)),
]
command = ["node", "-e", VOICE_PROBE, str(VOICE), json.dumps(cls.settings)]
done = subprocess.run(command, capture_output=True, text=True, timeout=60, check=True)
@@ -63,7 +65,11 @@ class TestVoiceModule(unittest.TestCase):
for loud in self.data["loud"]:
with self.subTest(loud=loud):
self.assertEqual(loud["mayday"], max(loud.values()))
self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8])
self.assertEqual([loud["mayday"] for loud in self.data["loud"]], [0.9, 1.0, 0.8, 0.0, 0.0])
def test_squelch_at_zero_plays_no_tone_with_music_on_or_off(self):
self.assertEqual(self.data["beeps"], ["mayday", None, "mayday", None, None])
self.assertEqual([set(loud.values()) for loud in self.data["loud"][3:]], [{0.0}, {0.0}])
def test_the_radio_filter_picks_classes_and_stations(self):
self.assertEqual(self.data["spoken"], [