13 Commits

Author SHA1 Message Date
f02eaa6bad chore(submodules): bump led-tool for Web Serial fixes
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7015032f5c test: cover zone content kind lock and sequence groups
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
d7a3fa96c5 feat(db): add Winter profile with 2x3 grid sequences
Winter profile, scoped groups, presets, and five multi-lane sequences;
include setup script for regeneration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
7a7bedc07c fix(sequences): target only checked lane groups
Use zone group checkboxes in the editor; empty lane groups no longer
fall back to the whole zone. Remove cross-lane device splitting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:21 +12:00
baec87068a feat(ui): lock zone type and start audio from BPM
Zone preset vs sequence is fixed at create; edit shows read-only type.
Header BPM button starts beat detection when the detector is stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 00:23:15 +12:00
b140aedf00 chore(submodules): bump led-tool for settings editor
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:24 +12:00
15f8c8a039 fix(wifi): limit outbound driver WS to hello-triggered attempts
Remove periodic UDP hello loop; dial each driver at most
wifi_driver_initial_connect_attempts times per discovery hello.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:22 +12:00
70641c63af feat(led-tool): embed settings editor in main UI
Serve led-tool static editor at /led-tool/editor, filter host serial
ports, and load the modal via iframe instead of the legacy form.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 14:54:18 +12:00
ef15c54593 chore(submodules): bump led-driver and led-tool for file_hashes deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:14:54 +12:00
301e1c64bf test: cover audio, sequences, pattern direction, and settings
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
c286e504eb feat(ui): numpad, audio readout, and sequence beat controls
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:12 +12:00
964cfc6d91 feat(audio-sequences): beat phase sync and aligned playback
Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase
sequence switching, sync-phase endpoint, and sample sequence data.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:10 +12:00
7ecb5c3b3e chore(submodules): bump led-driver for pattern reverse
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:32:07 +12:00
46 changed files with 3261 additions and 984 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": []}
{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]}

View File

@@ -12,6 +12,7 @@
"supports_manual": true
},
"colour_cycle": {
"supports_reverse": true,
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
@@ -29,6 +30,7 @@
"supports_manual": false
},
"chase": {
"supports_reverse": true,
"n1": "Colour 1 Length",
"n2": "Colour 2 Length",
"n3": "Step 1",
@@ -155,6 +157,7 @@
"supports_manual": true
},
"aurora": {
"supports_reverse": true,
"n1": "Band count (0) or spatial period LEDs (1)",
"n2": "Shimmer (0) or blend strength (1)",
"n3": "Unused (0) or drift speed (1)",
@@ -169,6 +172,7 @@
"supports_manual": true
},
"icicles": {
"supports_reverse": true,
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
@@ -179,6 +183,7 @@
"supports_manual": true
},
"blizzard": {
"supports_reverse": true,
"n1": "Flake density",
"n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
@@ -227,6 +232,7 @@
"supports_manual": false
},
"meteor": {
"supports_reverse": true,
"n1": "Tail length (01) or eye width (2)",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
@@ -242,6 +248,7 @@
"supports_manual": true
},
"particles": {
"supports_reverse": true,
"n1": "Flake density (0) or spawn rate (1)",
"n2": "Fall speed (LEDs per frame)",
"n3": "Unused (0) or streak length (1)",

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}}
{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""Add Winter profile: 6-light 2x3 grid, presets, and sequences."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DB = ROOT / "db"
PROFILE_ID = "3"
PALETTE_ID = "14"
ZONE_PRESETS_ID = "11"
ZONE_SEQUENCES_ID = "12"
# 2x3 grid device MACs (placeholders — assign real devices in the UI)
DEVICE_MACS = [
"a0b100000001", # r0c0 top-left
"a0b100000002", # r0c1
"a0b100000003", # r0c2
"a0b100000004", # r1c0 bottom-left
"a0b100000005", # r1c1
"a0b100000006", # r1c2
]
GROUP_CELL = {
"a0b100000001": "6",
"a0b100000002": "7",
"a0b100000003": "8",
"a0b100000004": "9",
"a0b100000005": "10",
"a0b100000006": "11",
}
GROUP_TOP_ROW = "12"
GROUP_BOTTOM_ROW = "13"
GROUP_COL_LEFT = "14"
GROUP_COL_MID = "15"
GROUP_COL_RIGHT = "16"
GROUP_ALL = "17"
PRESET_OFF = "78"
PRESET_TWINKLE = "79"
PRESET_ICICLES = "80"
PRESET_BLIZZARD = "81"
PRESET_RIME = "82"
PRESET_AURORA = "83"
PRESET_STARFALL = "84"
PRESET_SPARKLE = "85"
PRESET_COOL_WHITE = "86"
PRESET_CHASE_ICE = "87"
SEQ_CASCADE = "12"
SEQ_ROWS = "13"
SEQ_COLUMNS = "14"
SEQ_BLIZZARD_ALL = "15"
SEQ_ROTATION = "16"
def load_json(name: str) -> dict:
path = DB / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
def save_json(name: str, data: dict) -> None:
path = DB / f"{name}.json"
path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
def preset_skeleton(name: str, pattern: str, colors: list, **extra) -> dict:
doc = {
"name": name,
"pattern": pattern,
"colors": colors,
"brightness": 220,
"delay": 80,
"auto": True,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
"profile_id": PROFILE_ID,
"background": "#0A1520",
"manual_beat_n": 1,
}
doc.update(extra)
if "palette_refs" not in doc and pattern not in ("on", "off"):
doc["palette_refs"] = [None] * len(colors)
return doc
def seq_doc(
name: str,
lanes: list,
lanes_group_ids: list,
*,
loop: bool = True,
simulated_bpm: int = 90,
) -> dict:
steps = [step for lane in lanes for step in lane]
return {
"name": name,
"profile_id": PROFILE_ID,
"group_ids": [GROUP_ALL],
"lanes": lanes,
"lanes_group_ids": lanes_group_ids,
"advance_mode": "beats",
"steps": steps,
"step_duration_ms": 3000,
"simulated_bpm": simulated_bpm,
"sequence_transition": 500,
"loop": loop,
}
def main() -> None:
profiles = load_json("profile")
palettes = load_json("palette")
groups = load_json("group")
devices = load_json("device")
zones = load_json("zone")
sequences = load_json("sequence")
presets = load_json("preset")
labels = [
("winter top-left", 0),
("winter top-centre", 1),
("winter top-right", 2),
("winter bottom-left", 3),
("winter bottom-centre", 4),
("winter bottom-right", 5),
]
profiles[PROFILE_ID] = {
"name": "Winter",
"type": "zones",
"zones": [ZONE_PRESETS_ID, ZONE_SEQUENCES_ID],
"scenes": [],
"palette_id": PALETTE_ID,
}
palettes[PALETTE_ID] = [
"#E8F4FF",
"#9ECFFF",
"#5080C8",
"#FFFFFF",
"#B0DCFF",
"#0A1520",
"#FF8020",
"#071018",
]
for mac, (label, _idx) in zip(DEVICE_MACS, labels):
devices[mac] = {
"id": mac,
"name": label,
"type": "led",
"transport": "wifi",
"address": "",
"default_pattern": None,
"zones": [],
"output_brightness": 255,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
}
def group_row(gid: str, name: str, macs: list) -> None:
groups[gid] = {
"name": name,
"devices": macs,
"profile_id": PROFILE_ID,
"wifi_color_order": "rgb",
"wifi_startup_mode": "default",
"output_brightness": 255,
"pattern": "on",
"colors": ["000000", "E8F4FF"],
"brightness": 100,
"delay": 100,
"step_offset": 0,
"step_increment": 1,
"n1": 0,
"n2": 0,
"n3": 0,
"n4": 0,
"n5": 0,
"n6": 0,
"n7": 0,
"n8": 0,
}
for mac, gid in zip(DEVICE_MACS, GROUP_CELL.values()):
group_row(gid, labels[DEVICE_MACS.index(mac)][0], [mac])
group_row(GROUP_TOP_ROW, "winter top row", DEVICE_MACS[:3])
group_row(GROUP_BOTTOM_ROW, "winter bottom row", DEVICE_MACS[3:])
group_row(GROUP_COL_LEFT, "winter left column", [DEVICE_MACS[0], DEVICE_MACS[3]])
group_row(GROUP_COL_MID, "winter centre column", [DEVICE_MACS[1], DEVICE_MACS[4]])
group_row(GROUP_COL_RIGHT, "winter right column", [DEVICE_MACS[2], DEVICE_MACS[5]])
group_row(GROUP_ALL, "winter grid (all)", list(DEVICE_MACS))
presets[PRESET_OFF] = preset_skeleton("winter off", "off", [], brightness=0, delay=100)
presets[PRESET_TWINKLE] = preset_skeleton(
"winter twinkle",
"twinkle",
["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
n1=150,
n2=20,
n4=10,
delay=100,
)
presets[PRESET_ICICLES] = preset_skeleton(
"winter icicles",
"icicles",
["#F0F8FF", "#9ECFFF", "#FFFFFF"],
n1=14,
n2=11,
n3=1,
delay=80,
)
presets[PRESET_BLIZZARD] = preset_skeleton(
"winter blizzard",
"blizzard",
["#FFFFFF", "#CDE8FF", "#AACCF5"],
n1=110,
n2=2,
n3=140,
delay=45,
)
presets[PRESET_RIME] = preset_skeleton(
"winter rime",
"rime",
["#E8F4FF", "#FFFFFF", "#B8DCF8"],
n1=40,
n2=18,
n3=4,
delay=120,
)
presets[PRESET_AURORA] = preset_skeleton(
"winter aurora",
"aurora",
["#183050", "#5090C8", "#C8E8FF"],
n1=22,
n2=210,
n6=1,
delay=90,
)
presets[PRESET_STARFALL] = preset_skeleton(
"winter starfall",
"particles",
["#FFFFFF", "#C8E8FF", "#FFF8E0"],
n1=16,
n2=2,
n3=12,
n6=1,
delay=55,
)
presets[PRESET_SPARKLE] = preset_skeleton(
"winter ice sparkle",
"sparkle",
["#E8F4FF", "#B0DCFF", "#FFFFFF"],
n1=70,
n2=165,
n3=1,
n6=1,
delay=50,
)
presets[PRESET_COOL_WHITE] = preset_skeleton(
"winter cool white",
"on",
["#E6F2FF"],
brightness=200,
delay=100,
)
presets[PRESET_CHASE_ICE] = preset_skeleton(
"winter ice chase",
"chase",
["#E8F4FF", "#5080C8"],
auto=False,
n1=20,
n2=20,
n3=15,
n4=15,
delay=120,
background="#071018",
)
grid_presets = [
[PRESET_ICICLES, PRESET_TWINKLE, PRESET_BLIZZARD],
[PRESET_RIME, PRESET_AURORA, PRESET_STARFALL],
]
flat = [p for row in grid_presets for p in row]
zones[ZONE_PRESETS_ID] = {
"name": "Winter grid",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": grid_presets,
"presets_flat": flat,
"default_preset": PRESET_TWINKLE,
"brightness": 200,
"sequence_ids": [],
"content_kind": "presets",
}
sequences[SEQ_CASCADE] = seq_doc(
"Winter cell cascade",
[
[{"preset_id": PRESET_ICICLES, "beats": 6}],
[{"preset_id": PRESET_SPARKLE, "beats": 6}],
[{"preset_id": PRESET_BLIZZARD, "beats": 6}],
[{"preset_id": PRESET_RIME, "beats": 6}],
[{"preset_id": PRESET_AURORA, "beats": 6}],
[{"preset_id": PRESET_STARFALL, "beats": 6}],
],
[
[GROUP_CELL[DEVICE_MACS[0]]],
[GROUP_CELL[DEVICE_MACS[1]]],
[GROUP_CELL[DEVICE_MACS[2]]],
[GROUP_CELL[DEVICE_MACS[3]]],
[GROUP_CELL[DEVICE_MACS[4]]],
[GROUP_CELL[DEVICE_MACS[5]]],
],
simulated_bpm=85,
)
sequences[SEQ_ROWS] = seq_doc(
"Winter row waves",
[
[
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_ICICLES, "beats": 8},
],
[
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
],
],
[[GROUP_TOP_ROW], [GROUP_BOTTOM_ROW]],
simulated_bpm=80,
)
sequences[SEQ_COLUMNS] = seq_doc(
"Winter column chase",
[
[{"preset_id": PRESET_CHASE_ICE, "beats": 12}],
[{"preset_id": PRESET_TWINKLE, "beats": 12}],
[{"preset_id": PRESET_STARFALL, "beats": 12}],
],
[[GROUP_COL_LEFT], [GROUP_COL_MID], [GROUP_COL_RIGHT]],
simulated_bpm=95,
)
sequences[SEQ_BLIZZARD_ALL] = seq_doc(
"Winter full blizzard",
[[{"preset_id": PRESET_BLIZZARD, "beats": 16}]],
[[GROUP_ALL]],
simulated_bpm=75,
)
sequences[SEQ_ROTATION] = seq_doc(
"Winter showcase",
[
[
{"preset_id": PRESET_ICICLES, "beats": 8},
{"preset_id": PRESET_BLIZZARD, "beats": 8},
{"preset_id": PRESET_RIME, "beats": 8},
{"preset_id": PRESET_AURORA, "beats": 8},
{"preset_id": PRESET_STARFALL, "beats": 8},
{"preset_id": PRESET_TWINKLE, "beats": 8},
]
],
[[GROUP_ALL]],
simulated_bpm=72,
)
zones[ZONE_SEQUENCES_ID] = {
"name": "Winter sequences",
"names": [],
"group_ids": [GROUP_ALL],
"preset_group_ids": {},
"presets": [],
"presets_flat": [],
"default_preset": None,
"brightness": 200,
"sequence_ids": [
SEQ_CASCADE,
SEQ_ROWS,
SEQ_COLUMNS,
SEQ_BLIZZARD_ALL,
SEQ_ROTATION,
],
"content_kind": "sequences",
}
save_json("profile", profiles)
save_json("palette", palettes)
save_json("group", groups)
save_json("device", devices)
save_json("zone", zones)
save_json("sequence", sequences)
save_json("preset", presets)
print("Winter profile created:")
print(f" profile {PROFILE_ID}, palette {PALETTE_ID}")
print(f" zones {ZONE_PRESETS_ID} (presets 2x3), {ZONE_SEQUENCES_ID} (sequences)")
print(f" devices {', '.join(DEVICE_MACS)}")
print(f" groups {GROUP_CELL} + rows/cols/all")
print(f" presets {PRESET_OFF}-{PRESET_CHASE_ICE}")
print(f" sequences {SEQ_CASCADE}-{SEQ_ROTATION}")
if __name__ == "__main__":
main()

View File

@@ -8,7 +8,7 @@ from models.device import (
)
from models.group import Group
from models.transport import get_current_sender
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
from models.wifi_ws_clients import (
normalize_tcp_peer_ip,
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
controller = Microdot()
devices = Device()
_group_registry = Group()
_pi_settings = Settings()
_pi_settings = get_settings()
def _device_live_connected(dev_dict):

View File

@@ -5,14 +5,14 @@ from models.group import Group
from models.device import Device
from models.transport import get_current_sender
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
from settings import Settings
from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
groups = Group()
devices = Device()
_pi_settings = Settings()
_pi_settings = get_settings()
def _group_doc_visible_for_profile(doc, profile_id):

View File

@@ -3,20 +3,40 @@ import os
import subprocess
import sys
from microdot import Microdot
from microdot import Microdot, send_file
from serial.tools import list_ports
controller = Microdot()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
def _led_tool_static_dir() -> str:
return os.path.join(_repo_root(), "led-tool", "static")
def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py")
def _filter_host_serial_ports(ports: list) -> list:
mod_path = os.path.join(_repo_root(), "led-tool", "host_ports.py")
if not os.path.isfile(mod_path):
return ports
import importlib.util
spec = importlib.util.spec_from_file_location("led_tool_host_ports", mod_path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod.filter_port_dicts(ports)
def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -92,17 +112,41 @@ def _extract_settings_from_stdout(stdout: str):
return None
@controller.get("/editor")
async def settings_editor_page(request):
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
if not os.path.isfile(path):
return (
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
404,
{"Content-Type": "application/json"},
)
return send_file(path)
@controller.get("/static/<path:filename>")
async def led_tool_static(request, filename):
if filename not in _STATIC_ALLOWED:
return "Not found", 404
path = os.path.join(_led_tool_static_dir(), filename)
if not os.path.isfile(path):
return "Not found", 404
return send_file(path)
@controller.get("/ports")
async def list_serial_ports(request):
ports = []
for info in list_ports.comports():
ports.append(
ports = _filter_host_serial_ports(
[
{
"device": info.device,
"description": info.description,
"hwid": info.hwid,
}
)
for info in list_ports.comports()
]
)
return (
json.dumps(
{

View File

@@ -30,6 +30,7 @@ def get_current_profile_id(session=None):
@with_session
async def list_sequences(request, session):
"""List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
@@ -97,6 +98,7 @@ async def import_sequence(request, session):
@with_session
async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if (
@@ -203,15 +205,46 @@ async def delete_sequence(request, session, id):
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/sync-phase")
@with_session
async def sync_sequence_beat_phase(request, session):
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
_ = session
try:
data = request.json or {}
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
mode = data.get("mode") or data.get("align") or "step"
try:
from util.sequence_playback import sync_beat_phase
if not await sync_beat_phase(str(mode)):
return (
json.dumps({"error": "No sequence is playing"}),
409,
{"Content-Type": "application/json"},
)
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
"Content-Type": "application/json"
}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@controller.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop
from util.sequence_playback import stop_playback
stop()
await stop_playback(clear_devices=True)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@@ -251,8 +284,12 @@ async def play_sequence(request, session, id):
try:
from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
play_opts = data if isinstance(data, dict) else None
await start(zone_id, str(id), str(current_profile_id), play_opts)
from util.sequence_playback import pending_play_status
body = {"ok": True, **pending_play_status()}
return json.dumps(body), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e:

View File

@@ -4,10 +4,10 @@ import json
from microdot import Microdot, send_file
from models import wifi_ws_clients
from settings import Settings
from settings import get_settings
controller = Microdot()
settings = Settings()
settings = get_settings()
@controller.get('')
async def get_settings(request):
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
return v
@controller.put('/settings')
def _validate_sequence_switch_wait(value):
s = str(value).strip().lower()
if s not in ("beat", "downbeat"):
raise ValueError("sequence_switch_wait must be beat or downbeat")
return s
def _validate_audio_beat_phase_ms(value):
v = int(value)
if v < 0 or v > 500:
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
return v
@controller.put('')
async def update_settings(request):
"""Update general settings."""
try:
@@ -87,6 +101,10 @@ async def update_settings(request):
elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value)
global_brightness_changed = True
elif key == 'sequence_switch_wait' and value is not None:
settings[key] = _validate_sequence_switch_wait(value)
elif key == 'audio_beat_phase_ms' and value is not None:
settings[key] = _validate_audio_beat_phase_ms(value)
else:
settings[key] = value
settings.save()

View File

@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
@controller.get("")
@with_session
async def list_zones(request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else []
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
@controller.get("/<id>")
async def get_zone(request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}

View File

@@ -10,7 +10,7 @@ import traceback
from microdot import Microdot, send_file
from microdot.websocket import with_websocket
from microdot.session import Session
from settings import Settings
from settings import get_settings
import controllers.preset as preset
import controllers.profile as profile
@@ -100,11 +100,7 @@ async def _handle_udp_discovery(sock, udp_holder=None) -> None:
def _prime_wifi_outbound_driver_connections() -> None:
"""
For each WiFi device in the registry with a usable IPv4, start (or keep) the
outbound WebSocket task. The client loop reconnects automatically if the link
drops. Presets are not pushed automatically; use Send Presets / profile apply.
"""
"""On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
n = 0
try:
dev = Device()
@@ -143,65 +139,6 @@ def _ipv4_address(addr: str) -> str | None:
return s
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
"""
While a registered Wi-Fi driver has no outbound WebSocket, send a short JSON hello on
UDP discovery port so the device can announce itself and we can reconnect.
"""
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
loop = asyncio.get_running_loop()
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
await loop.sock_sendto(
sock, line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT)
)
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
finally:
try:
sock.close()
except OSError:
pass
async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
@@ -244,7 +181,7 @@ async def _send_bridge_wifi_channel(settings, sender):
async def main(port=80):
settings = Settings()
settings = get_settings()
print(settings)
print("Starting")
@@ -377,7 +314,12 @@ async def main(port=80):
audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=True, device=device)
write_audio_run_state(
enabled=True,
device=device,
device_override=str(payload.get("device_override") or ""),
device_select=str(payload.get("device_select") or ""),
)
return {"ok": True, "status": audio_detector.status()}
except Exception as e:
return {"ok": False, "error": str(e)}, 500
@@ -391,6 +333,24 @@ async def main(port=80):
write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/reset', methods=['POST'])
async def audio_reset(request):
"""Clear beat/BPM tracking state without stopping the detector."""
_ = request
ok = audio_detector.reset_tracking()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/anchor-bar', methods=['POST'])
async def audio_anchor_bar(request):
"""Mark the current moment as bar beat 1 (downbeat)."""
_ = request
ok = audio_detector.anchor_bar_phase()
if not ok:
return {"ok": False, "error": "Audio detector is not running"}, 409
return {"ok": True, "status": audio_detector.status()}
@app.route('/api/audio/status')
async def audio_status(request):
_ = request
@@ -426,6 +386,14 @@ async def main(port=80):
if bs > 0:
beat_readout = str(bs)
st["beat_readout"] = beat_readout
from util.audio_run_persist import read_audio_run_state
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
st["sequence_switch_wait"] = seq_wait
st["audio_run"] = read_audio_run_state()
return {"status": st}
# Static file route
@@ -480,16 +448,30 @@ async def main(port=80):
await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False}
udp_holder = {"closing": False, "shutting_down": False}
loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...")
udp_holder["closing"] = True
try:
audio_detector.stop()
except Exception:
pass
try:
from util import sequence_playback as seq_pb
seq_pb.stop()
for attr in ("_pending_beat_task", "_sim_beat_task"):
t = getattr(seq_pb, attr, None)
if t is not None and not t.done():
t.cancel()
except Exception:
pass
u = udp_holder.get("sock")
if u is not None:
try:
@@ -498,7 +480,13 @@ async def main(port=80):
pass
tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None:
app.shutdown()
try:
app.shutdown()
except Exception:
pass
for t in server_tasks:
if not t.done():
t.cancel()
shutdown_handlers_registered = False
try:
@@ -511,11 +499,17 @@ async def main(port=80):
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try:
await asyncio.gather(
app.start_server(host="0.0.0.0", port=port),
_run_udp_discovery_server(udp_holder),
_periodic_wifi_driver_hello_loop(settings, udp_holder),
)
server_tasks[:] = [
asyncio.create_task(
app.start_server(host="0.0.0.0", port=port), name="http"
),
asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
pass
except OSError as e:
if e.errno == errno.EADDRINUSE:
print(
@@ -540,6 +534,21 @@ async def main(port=80):
app.server = None
except Exception:
pass
udp_holder["closing"] = True
for t in list(server_tasks):
if not t.done():
t.cancel()
if server_tasks:
await asyncio.gather(*server_tasks, return_exceptions=True)
pending = [
t
for t in asyncio.all_tasks(loop)
if t is not asyncio.current_task() and not t.done()
]
for t in pending:
t.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM):
try:
@@ -549,5 +558,9 @@ async def main(port=80):
if __name__ == "__main__":
import os
port = int(os.environ.get("PORT", 80))
asyncio.run(main(port=port))
try:
asyncio.run(main(port=port))
except KeyboardInterrupt:
print("[server] interrupted")

View File

@@ -13,7 +13,6 @@ from websockets.exceptions import ConnectionClosed
_connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None
_tcp_status_broadcast = None
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
if not key:
return
_connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks:
_send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True)
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
if stagger > 0:
await asyncio.sleep(stagger)
# Only bound boot-time: after we have connected once, keep retrying (Wi-Fi drops, reboots).
connected_once = False
boot_attempts = 0
try:
while True:
if not connected_once:
if boot_attempts >= max_boot_attempts:
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} "
f"initial dial attempt(s); stopping until next UDP hello / registry prime"
)
break
boot_attempts += 1
for attempt in range(1, max_boot_attempts + 1):
try:
print(f"[WS] connecting to {uri!r}")
print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect(
uri,
ping_interval=20,
ping_timeout=15,
open_timeout=open_timeout,
) as ws:
connected_once = True
_register_ws(ip, ws)
try:
await _recv_forward_loop(ip, ws)
finally:
unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError:
raise
except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None)
return
except Exception as e:
if _benign_ws_connect_failure(e):
n = _unreachable_counts.get(ip, 0) + 1
_unreachable_counts[ip] = n
if n == 1 or (n % 30) == 0:
print(
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})"
)
print(
f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
)
else:
print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None)
await asyncio.sleep(retry_interval_s)
if attempt < max_boot_attempts:
await asyncio.sleep(retry_interval_s)
print(
f"[WS] driver {ip} still unreachable after {max_boot_attempts} attempt(s); "
"waiting for next UDP hello"
)
except asyncio.CancelledError:
unregister_tcp_writer(ip, None)
raise
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
def ensure_driver_connection(peer_ip: str) -> None:
"""Start (or keep) a background task that maintains ``ws://<ip>:port/ws``."""
"""Dial ``ws://<ip>:port/ws`` up to wifi_driver_initial_connect_attempts times (UDP hello only)."""
key = normalize_tcp_peer_ip(peer_ip)
if not key:
return
if tcp_client_connected(key):
return
t = _tasks.get(key)
if t is not None and not t.done():
return
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False)
_connections.clear()
_send_locks.clear()
_unreachable_counts.clear()

View File

@@ -22,7 +22,7 @@ class Zone(Model):
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
"""
def __init__(self):
@@ -43,6 +43,12 @@ class Zone(Model):
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {}
changed = True
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
doc["sequence_ids"] = []
changed = True
if not self._normalized_content_kind(doc):
doc["content_kind"] = self._infer_content_kind(doc)
changed = True
if changed:
self.save()
@@ -53,6 +59,41 @@ class Zone(Model):
kind = doc.get("content_kind")
return kind if kind in ("presets", "sequences") else None
@staticmethod
def _preset_ids_in_doc(doc):
if not isinstance(doc, dict):
return []
flat = doc.get("presets_flat")
if isinstance(flat, list):
return [str(x) for x in flat if x is not None and str(x).strip()]
presets = doc.get("presets")
if not isinstance(presets, list) or not presets:
return []
if isinstance(presets[0], str):
return [str(x) for x in presets if x is not None and str(x).strip()]
if isinstance(presets[0], list):
out = []
for row in presets:
if isinstance(row, list):
out.extend(str(x) for x in row if x is not None and str(x).strip())
return out
return []
@classmethod
def _infer_content_kind(cls, doc):
kind = cls._normalized_content_kind(doc)
if kind:
return kind
seq_ids = [
str(x).strip()
for x in (doc.get("sequence_ids") or [])
if x is not None and str(x).strip()
]
preset_ids = cls._preset_ids_in_doc(doc)
if seq_ids and not preset_ids:
return "sequences"
return "presets"
def _enforce_content_kind_invariants(self, doc):
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
kind = self._normalized_content_kind(doc)
@@ -93,9 +134,14 @@ class Zone(Model):
id_str = str(id)
if id_str not in self:
return False
patch = data if isinstance(data, dict) else {}
patch = dict(data) if isinstance(data, dict) else {}
doc = self[id_str]
locked_kind = self._normalized_content_kind(doc) or self._infer_content_kind(doc)
if "content_kind" in patch:
patch["content_kind"] = locked_kind
self[id_str].update(patch)
self._enforce_content_kind_invariants(self[id_str])
if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str])
self.save()
return True

View File

@@ -12,11 +12,15 @@ def _settings_path():
return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self):
def __init__(self, *, quiet: bool = False):
super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization
@@ -53,12 +57,9 @@ class Settings(dict):
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
# Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
self['wifi_driver_hello_interval_s'] = 0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
# Spread outbound dials 0..N s by device IP so six+ drivers do not all hit the AP at once.
@@ -70,7 +71,7 @@ class Settings(dict):
# Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Outbound dial attempts to the saved driver IP before first success; then wait for UDP discovery.
# Outbound WebSocket dial attempts per driver UDP hello (then wait for next hello).
if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
@@ -79,13 +80,22 @@ class Settings(dict):
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
# Sequence tile start: wait for beat or downbeat (server-owned).
if 'sequence_switch_wait' not in self:
self['sequence_switch_wait'] = 'beat'
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
self['sequence_switch_wait'] = 'beat'
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
if 'audio_beat_phase_ms' not in self:
self['audio_beat_phase_ms'] = 0
def save(self):
try:
j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file:
file.write(j)
print("Settings saved successfully.")
if not getattr(self, "_quiet", False):
print("Settings saved successfully.")
except Exception as e:
print(f"Error saving settings: {e}")
@@ -96,9 +106,11 @@ class Settings(dict):
loaded_settings = json.load(file)
self.update(loaded_settings)
loaded_from_file = True
print("Settings loaded successfully.")
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.")
except Exception as e:
print(f"Error loading settings")
if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear()
finally:
# Ensure defaults are set even if file exists but is missing keys
@@ -106,3 +118,18 @@ class Settings(dict):
# Only save if file didn't exist or was invalid
if not loaded_from_file:
self.save()
def get_settings() -> Settings:
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
global _settings_singleton
if _settings_singleton is None:
_settings_singleton = Settings()
return _settings_singleton
def reload_settings() -> Settings:
"""Re-read settings.json (e.g. after external file edit)."""
global _settings_singleton
_settings_singleton = Settings(quiet=True)
return _settings_singleton

View File

@@ -1,5 +1,6 @@
(() => {
let pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
@@ -14,49 +15,6 @@
/** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set();
const STORAGE_KEY = "led-controller-audio-restore";
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
const STORAGE_VERSION = 1;
function readRestorePrefs() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
return {
override: typeof o.override === "string" ? o.override : "",
select: typeof o.select === "string" ? o.select : "",
};
} catch {
return null;
}
}
function writeRestorePrefs(override, select) {
try {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
v: STORAGE_VERSION,
restore: true,
override: override || "",
select: select || "",
}),
);
} catch (e) {
console.warn("audio restore prefs save failed", e);
}
}
function clearRestorePrefs() {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.warn("audio restore prefs clear failed", e);
}
}
function el(id) {
return document.getElementById(id);
}
@@ -155,21 +113,115 @@
node.textContent = `${label}${conf}`;
}
/** @param {Record<string, unknown>} status */
function updateBarPhaseDisplay(status) {
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
}
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id);
if (!node) continue;
node.textContent = status && status.running ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout);
}
}
function setTopBpmVisible(on) {
const top = el("audio-top-indicator");
if (!top) return;
top.classList.toggle("audio-running", !!on);
}
function setNavResetVisible(on) {
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
const node = el(id);
if (node) node.hidden = !on;
}
}
async function resetAudioTracking() {
try {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
console.warn("audio reset failed", data.error || res.status);
return;
}
await pollStatus();
} catch (e) {
console.warn("audio reset failed", e);
}
}
function updateSequenceSyncControls(zoneSeqActive) {
const topSync = el("audio-top-beat-sync");
if (topSync) {
topSync.disabled = audioDetectorRunning && !zoneSeqActive;
topSync.title = !audioDetectorRunning
? "Start beat detection"
: zoneSeqActive
? "Sync step to music (S)"
: "Beat detection running";
}
const modalBeat = el("audio-modal-beat-readout");
if (modalBeat) modalBeat.disabled = !zoneSeqActive;
const passBtn = el("audio-sync-pass-btn");
if (passBtn) passBtn.disabled = !zoneSeqActive;
}
async function handleTopBpmButtonClick() {
if (!audioDetectorRunning) {
try {
await startAudio();
} catch (e) {
console.error("audio start failed", e);
alert("Failed to start audio input. Check mic permissions.");
}
return;
}
try {
await syncSequenceBeatPhase("step");
} catch (e) {
console.warn("sequence beat sync failed", e);
}
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
await pollStatus();
}
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeat() {
const node = el("audio-beat-flash");
if (!node) return;
node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80);
const syncBtn = el("audio-top-beat-sync");
const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) {
top.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90);
if (syncBtn && top && top.classList.contains("audio-running")) {
syncBtn.classList.add("flash");
setTimeout(() => syncBtn.classList.remove("flash"), 90);
}
}
@@ -184,17 +236,17 @@
const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
}
try {
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
} catch {
return 0;
}
return 0;
}
function persistBeatPhaseMs() {
async function persistBeatPhaseMs() {
const ms = getBeatPhaseDelayMs();
try {
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_beat_phase_ms: ms }),
});
} catch (e) {
console.warn("beat phase ms save failed", e);
}
@@ -223,7 +275,9 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setNavResetVisible(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
@@ -241,10 +295,9 @@
}
}
/** User-initiated stop: also forget auto-restart on next page load. */
/** User-initiated stop (run intent cleared on server). */
async function stopAudio() {
await stopAudioOnly();
clearRestorePrefs();
}
async function pollStatus() {
@@ -258,24 +311,34 @@
node.textContent = String(status.error).trim().slice(0, 120);
}
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
return;
}
setTopBpmVisible(!!status.running);
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
applyServerAudioUiFields(status);
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
/*
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
@@ -320,7 +383,11 @@
const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
const body = { device: rawDevice === "" ? null : numeric };
const body = {
device: rawDevice === "" ? null : numeric,
device_override: override,
device_select: selected,
};
const res = await fetch("/api/audio/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -330,7 +397,6 @@
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector");
}
writeRestorePrefs(override, selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
@@ -378,6 +444,7 @@
const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn");
const navResetBtn = el("audio-nav-reset-btn");
const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return;
@@ -410,6 +477,9 @@
await stopAudio();
});
}
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) {
refreshBtn.addEventListener("click", async () => {
try {
@@ -422,17 +492,41 @@
const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) {
try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
if (Number.isFinite(stored)) {
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
}
} catch {
/* ignore */
}
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
});
}
const bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try {
await syncSequenceBeatPhase(mode);
} catch (e) {
console.warn("sequence beat sync failed", e);
}
});
};
const topBpm = el("audio-top-beat-sync");
if (topBpm) {
topBpm.addEventListener("click", () => {
void handleTopBpmButtonClick();
});
}
bindSync(el("audio-modal-beat-readout"), "step");
bindSync(el("audio-sync-pass-btn"), "pass");
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const k = String(ev.key || "").toLowerCase();
if (k !== "s") return;
ev.preventDefault();
const mode = ev.shiftKey ? "pass" : "step";
void syncSequenceBeatPhase(mode).catch((e) => console.warn("sequence beat sync failed", e));
});
}
async function resumePollingIfDetectorRunning() {
@@ -440,38 +534,74 @@
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
}
} catch (e) {
console.warn("audio resume poll check failed", e);
}
}
/**
* Apply browser-stored device fields only (GET /devices list); does not start detection.
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
*/
async function applySavedAudioDeviceFormOnly() {
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || "";
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
function applyServerAudioUiFields(status) {
if (!status || typeof status !== "object") return;
const run = status.audio_run;
if (run && typeof run === "object") {
const ov = el("audio-device-override");
const sel = el("audio-device-select");
if (ov && run.device_override != null) ov.value = String(run.device_override);
if (sel && run.device_select) sel.value = String(run.device_select);
}
const phaseInp = el("audio-beat-phase-ms");
if (
phaseInp &&
status.beat_phase_ms != null &&
document.activeElement !== phaseInp
) {
const ms = parseInt(String(status.beat_phase_ms), 10);
if (Number.isFinite(ms)) {
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
}
}
}
async function loadServerAudioUiFields() {
try {
await refreshDevices();
} catch (e) {
console.warn("audio device list refresh failed", e);
}
if (sel && prefs.select) sel.value = prefs.select;
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
applyServerAudioUiFields(data?.status || {});
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops without audio polling. */
window.ledControllerSequencePlaybackChanged = (active) => {
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
return;
}
if (!pollTimer) {
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning();
await applySavedAudioDeviceFormOnly();
});
})();

View File

@@ -131,7 +131,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
try {
const response = await fetch('/settings/settings', {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
const openBtn = document.getElementById('led-tool-btn');
const modal = document.getElementById('led-tool-modal');
const closeBtn = document.getElementById('led-tool-close-btn');
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn');
const form = document.getElementById('led-tool-form');
const readBtn = document.getElementById('led-tool-read-btn');
const resetBtn = document.getElementById('led-tool-reset-btn');
const portSelect = document.getElementById('led-tool-port');
const outputEl = document.getElementById('led-tool-output');
const messageEl = document.getElementById('led-tool-message');
const iframe = document.getElementById('led-tool-iframe');
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) {
if (!openBtn || !modal || !iframe) {
return;
}
const showMessage = (text, type = 'success') => {
messageEl.textContent = text;
messageEl.className = `message ${type} show`;
};
const setOutput = (text) => {
outputEl.value = text || '';
};
const parseApiResponse = async (response) => {
const bodyText = await response.text();
let data = null;
try {
data = bodyText ? JSON.parse(bodyText) : {};
} catch (error) {
data = { error: bodyText || `HTTP ${response.status}` };
}
return data;
};
const setFieldValue = (id, value) => {
const el = document.getElementById(id);
if (!el) return;
if (value === undefined || value === null) return;
el.value = String(value);
};
const populateFormFromSettings = (settings) => {
if (!settings || typeof settings !== 'object') return false;
setFieldValue('led-tool-name', settings.name);
setFieldValue('led-tool-num-leds', settings.num_leds);
setFieldValue('led-tool-led-pin', settings.led_pin);
setFieldValue('led-tool-brightness', settings.brightness);
setFieldValue('led-tool-transport', settings.transport_type);
setFieldValue('led-tool-ssid', settings.ssid);
setFieldValue('led-tool-password', settings.password);
setFieldValue('led-tool-wifi-channel', settings.wifi_channel);
setFieldValue('led-tool-default', settings.default);
return true;
};
const loadPorts = async () => {
const defaultPort = '/dev/ttyACM0';
try {
const response = await fetch('/led-tool/ports');
const data = await response.json();
const previous = portSelect.value;
portSelect.innerHTML = '<option value="">Select a serial port</option>';
for (const port of data.ports || []) {
const option = document.createElement('option');
option.value = port.device;
option.textContent = `${port.device} - ${port.description || 'Unknown'}`;
portSelect.appendChild(option);
}
if (previous) {
portSelect.value = previous;
} else if ((data.ports || []).some((p) => p.device === defaultPort)) {
portSelect.value = defaultPort;
} else {
const fallback = document.createElement('option');
fallback.value = defaultPort;
fallback.textContent = `${defaultPort} - default`;
portSelect.appendChild(fallback);
portSelect.value = defaultPort;
}
if (!data.led_cli_exists) {
showMessage('led-tool/cli.py was not found on the host.', 'error');
} else if ((data.ports || []).length === 0) {
showMessage('No serial ports found.', 'error');
} else {
showMessage(`Found ${(data.ports || []).length} serial port(s).`, 'success');
}
} catch (error) {
showMessage(`Failed to read serial ports: ${error.message}`, 'error');
}
};
openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active');
loadPorts();
});
if (closeBtn) {
closeBtn.addEventListener('click', () => {
modal.classList.remove('active');
iframe.src = 'about:blank';
});
}
if (refreshPortsBtn) {
refreshPortsBtn.addEventListener('click', () => {
loadPorts();
});
}
if (readBtn) {
readBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Reading settings from device...');
showMessage('Reading settings over USB...', 'success');
try {
const response = await fetch(`/led-tool/settings?port=${encodeURIComponent(port)}`);
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Read failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
const populated = populateFormFromSettings(data.settings);
if (populated) {
showMessage('Settings read and fields populated.', 'success');
} else {
showMessage('Settings read successfully.', 'success');
}
} else {
showMessage('Read completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
if (resetBtn) {
resetBtn.addEventListener('click', async () => {
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
setOutput('Resetting device and following output...');
showMessage('Resetting device over USB...', 'success');
try {
const response = await fetch('/led-tool/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ port }),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Reset failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Device reset complete.', 'success');
} else {
showMessage('Reset completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const port = portSelect.value.trim();
if (!port) {
showMessage('Select a serial port first.', 'error');
return;
}
const payload = {
port,
name: document.getElementById('led-tool-name')?.value?.trim() || '',
num_leds: document.getElementById('led-tool-num-leds')?.value?.trim() || '',
led_pin: document.getElementById('led-tool-led-pin')?.value?.trim() || '',
brightness: document.getElementById('led-tool-brightness')?.value?.trim() || '',
transport: document.getElementById('led-tool-transport')?.value?.trim() || '',
ssid: document.getElementById('led-tool-ssid')?.value?.trim() || '',
password: document.getElementById('led-tool-password')?.value?.trim() || '',
wifi_channel: document.getElementById('led-tool-wifi-channel')?.value?.trim() || '',
default: document.getElementById('led-tool-default')?.value?.trim() || '',
};
setOutput('Running led-tool command...');
showMessage('Running command over USB...', 'success');
try {
const response = await fetch('/led-tool/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await parseApiResponse(response);
if (!response.ok) {
showMessage(data.error || 'Command failed.', 'error');
setOutput(data.error || 'Request failed.');
return;
}
const output = [
`exit code: ${data.returncode}`,
'',
'stdout:',
data.stdout || '(none)',
'',
'stderr:',
data.stderr || '(none)',
].join('\n');
setOutput(output);
if (data.ok) {
showMessage('Settings applied via USB.', 'success');
} else {
showMessage('Command completed with errors. Check output.', 'error');
}
} catch (error) {
showMessage(`Request failed: ${error.message}`, 'error');
setOutput(error.message);
}
});
});

117
src/static/numpad.js Normal file
View File

@@ -0,0 +1,117 @@
/**
* Bluetooth / USB HID numpad shortcuts (browser focus required).
*
* Numpad19,0 → zone 110 (visible zone list order)
* NumpadEnter → sequence beat sync (step), same as S
* NumpadDecimal → sequence beat sync (pass), same as Shift+S
* NumpadMultiply → reset audio detector
* NumpadAdd → brightness +16
* NumpadSubtract → brightness 16
* NumpadDivide → stop zone sequence playback
*/
(() => {
const BRIGHTNESS_STEP = 16;
function isTypingTarget(target) {
if (!target || typeof target !== "object") return false;
const tag = String(target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function zoneIdsInListOrder() {
return [...document.querySelectorAll("#zones-list .zone-button[data-zone-id]")]
.map((el) => el.getAttribute("data-zone-id"))
.filter((id) => id != null && id !== "");
}
async function selectZoneByListIndex(oneBased) {
const order = zoneIdsInListOrder();
if (oneBased < 1 || oneBased > order.length) return;
const zoneId = order[oneBased - 1];
if (window.tabsManager && typeof window.tabsManager.selectZone === "function") {
await window.tabsManager.selectZone(zoneId);
} else if (typeof selectZone === "function") {
await selectZone(zoneId);
}
}
async function syncSequenceBeatPhase(mode) {
const res = await fetch("/sequences/sync-phase", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ mode: mode || "step" }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Sync failed (${res.status})`);
}
}
async function resetAudioTracking() {
const res = await fetch("/api/audio/reset", {
method: "POST",
headers: { Accept: "application/json" },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `Reset failed (${res.status})`);
}
}
function adjustZoneBrightness(delta) {
const zoneId =
(window.tabsManager && typeof window.tabsManager.getCurrentTabId === "function"
? window.tabsManager.getCurrentTabId()
: null) ||
(window.tabsManager && typeof window.tabsManager.getCurrentZoneId === "function"
? window.tabsManager.getCurrentZoneId()
: null);
if (!zoneId) return;
const slider =
document.getElementById("header-brightness-slider") ||
document.getElementById("menu-brightness-slider");
if (!slider) return;
const cur = parseInt(slider.value, 10);
const base = Number.isFinite(cur) ? cur : 127;
const next = Math.max(0, Math.min(255, base + delta));
if (String(slider.value) === String(next)) return;
slider.value = String(next);
slider.dispatchEvent(new Event("input", { bubbles: true }));
}
async function stopSequencePlayback() {
if (typeof window.stopZoneSequencePlayback === "function") {
await window.stopZoneSequencePlayback(true);
}
}
/** @type {Record<string, () => void | Promise<void>>} */
const actions = {
NumpadEnter: () => syncSequenceBeatPhase("step"),
NumpadDecimal: () => syncSequenceBeatPhase("pass"),
NumpadMultiply: () => resetAudioTracking(),
NumpadAdd: () => adjustZoneBrightness(BRIGHTNESS_STEP),
NumpadSubtract: () => adjustZoneBrightness(-BRIGHTNESS_STEP),
NumpadDivide: () => stopSequencePlayback(),
Numpad1: () => selectZoneByListIndex(1),
Numpad2: () => selectZoneByListIndex(2),
Numpad3: () => selectZoneByListIndex(3),
Numpad4: () => selectZoneByListIndex(4),
Numpad5: () => selectZoneByListIndex(5),
Numpad6: () => selectZoneByListIndex(6),
Numpad7: () => selectZoneByListIndex(7),
Numpad8: () => selectZoneByListIndex(8),
Numpad9: () => selectZoneByListIndex(9),
Numpad0: () => selectZoneByListIndex(10),
};
document.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented || ev.repeat || isTypingTarget(ev.target)) return;
const code = ev.code;
if (!code || !code.startsWith("Numpad")) return;
const action = actions[code];
if (!action) return;
ev.preventDefault();
Promise.resolve(action()).catch((e) => console.warn("numpad shortcut failed:", e));
});
})();

View File

@@ -264,6 +264,8 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
const presetModeInput = document.getElementById('preset-mode-input');
const presetModeGroup = document.getElementById('preset-mode-group');
const presetReverseInput = document.getElementById('preset-reverse-input');
const presetReverseGroup = document.getElementById('preset-reverse-group');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return;
@@ -350,6 +352,22 @@ document.addEventListener('DOMContentLoaded', () => {
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
const patternSupportsReverse = (patternName) => {
const cfg = resolvePatternConfig(patternName);
return !!(cfg && cfg.supports_reverse);
};
const setPresetReverseFieldVisible = (show) => {
if (!presetReverseGroup) {
return;
}
presetReverseGroup.hidden = !show;
presetReverseGroup.style.display = show ? '' : 'none';
if (!show && presetReverseInput) {
presetReverseInput.checked = false;
}
};
const setPresetModeFieldVisible = (show) => {
if (!presetModeGroup) {
return;
@@ -773,6 +791,12 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
if (presetReverseInput) {
const n5raw = preset.n5;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
// Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
@@ -828,6 +852,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeInput) {
presetManualModeInput.checked = false;
}
if (presetReverseInput) {
presetReverseInput.checked = false;
}
setPresetReverseFieldVisible(false);
if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1';
}
@@ -872,7 +900,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData)
? window.zoneAllowsPresets(tabData, currentEditTabId)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
@@ -951,13 +979,20 @@ document.addEventListener('DOMContentLoaded', () => {
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
const reverseField = patternSupportsReverse(payload.pattern);
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
if (reverseField && nKey === 'n5') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
}
if (reverseField) {
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
}
if (modeEntries && presetModeInput) {
payload.mode = parseInt(presetModeInput.value, 10) || 0;
}
@@ -1065,9 +1100,19 @@ document.addEventListener('DOMContentLoaded', () => {
}
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
const reverseField = patternSupportsReverse(patternName);
if (modeEntries) {
visibleNKeys.delete('n6');
}
if (reverseField) {
visibleNKeys.delete('n5');
}
setPresetReverseFieldVisible(reverseField);
if (reverseField && presetReverseInput) {
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
}
if (presetModeInput) {
if (modeEntries) {
setPresetModeFieldVisible(true);
@@ -1355,7 +1400,7 @@ document.addEventListener('DOMContentLoaded', () => {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc)
!window.zoneAllowsPresets(zoneDoc, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
@@ -1495,7 +1540,7 @@ document.addEventListener('DOMContentLoaded', () => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
@@ -2214,7 +2259,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
throw new Error('This zone is for sequences only.');
}
@@ -2312,9 +2357,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck =
typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: null;
typeof window.effectiveZoneContentKind === 'function'
? window.effectiveZoneContentKind(tabData)
: typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData)
: 'presets';
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
@@ -2454,7 +2501,8 @@ const renderTabPresets = async (zoneId, options = {}) => {
if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
(typeof window.zoneAllowsSequences !== 'function' ||
window.zoneAllowsSequences(tabData, zoneId))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
}
@@ -2698,7 +2746,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData)
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only.');
return;

View File

@@ -1,14 +1,98 @@
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
/** @type {'beat'|'downbeat'} */
let sequenceSwitchWaitFor = 'beat';
let sequenceDebugEnabled = false;
let sequenceSwitchSaveInFlight = false;
async function loadSequenceSwitchWaitForFromServer() {
try {
const res = await fetch('/settings', {
cache: 'no-store',
headers: { Accept: 'application/json' },
});
if (!res.ok) return;
const data = await res.json();
const raw = data && data.sequence_switch_wait;
if (raw === 'downbeat' || raw === 'beat') {
sequenceSwitchWaitFor = raw;
} else if (raw === 'phrase') {
sequenceSwitchWaitFor = 'beat';
}
} catch {
/* keep default */
}
}
async function persistSequenceSwitchWaitFor() {
sequenceSwitchSaveInFlight = true;
try {
const res = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
});
if (!res.ok) {
console.warn('[sequence] could not save switch wait to server', res.status);
}
} catch (e) {
console.warn('[sequence] could not save switch wait to server', e);
} finally {
sequenceSwitchSaveInFlight = false;
}
}
function getSequenceSwitchWaitFor() {
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
}
async function setSequenceSwitchWaitFor(waitFor) {
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
updateSequenceSwitchToggleUI();
await persistSequenceSwitchWaitFor();
}
function updateSequenceSwitchToggleUI() {
const mode = getSequenceSwitchWaitFor();
const ariaLabels = {
beat: 'Switch sequence on beat',
downbeat: 'Switch sequence on downbeat',
};
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
});
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
});
}
async function initSequenceSwitchToggle() {
await loadSequenceSwitchWaitForFromServer();
updateSequenceSwitchToggleUI();
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
});
});
}
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
function applySequenceSwitchWaitFromServer(raw) {
if (sequenceSwitchSaveInFlight) return;
let mode = 'beat';
if (raw === 'downbeat') mode = 'downbeat';
else if (raw !== 'beat' && raw !== 'phrase') return;
if (mode === getSequenceSwitchWaitFor()) return;
sequenceSwitchWaitFor = mode;
updateSequenceSwitchToggleUI();
}
function seqDebugEnabled() {
try {
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
} catch {
return false;
}
return sequenceDebugEnabled;
}
/** @type {ReturnType<typeof setInterval> | null} */
@@ -132,6 +216,12 @@ function logSequenceSelectionPresets(sequenceId, sequenceDoc, presetsMap) {
});
}
function zoneGroupIdsFromDoc(zoneDoc) {
return Array.isArray(zoneDoc && zoneDoc.group_ids)
? zoneDoc.group_ids.map((x) => String(x).trim()).filter(Boolean)
: [];
}
function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) {
@@ -155,7 +245,6 @@ function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : [];
const shared = Array.isArray(doc && doc.group_ids) ? doc.group_ids.map(String) : [];
if (laneIndex < raw.length) {
const row = raw[laneIndex];
if (Array.isArray(row)) {
@@ -165,17 +254,10 @@ function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
if (numLanes > 1 && laneIndex >= raw.length) {
return [];
}
if (numLanes === 1) {
const lanes = normalizeSequenceLanes(doc);
const first = lanes[0] && lanes[0][0];
const sg =
first && Array.isArray(first.group_ids) ? first.group_ids.map(String).filter(Boolean) : [];
return sg.length ? sg : shared.slice();
}
return shared.slice();
return [];
}
function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;';
@@ -183,15 +265,17 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
hint.className = 'muted-text';
hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem';
hint.textContent = 'Groups for this lane (none = whole zone)';
hint.textContent = 'Only checked groups are used on this lane';
wrap.appendChild(hint);
const row = document.createElement('div');
row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap)
.sort((a, b) => a.localeCompare(b))
.forEach((gid) => {
const zg = Array.isArray(zoneGroupIds) ? zoneGroupIds.map(String).filter(Boolean) : [];
const gidsToShow = zg.length
? zg
: Object.keys(groupsMap).sort((a, b) => a.localeCompare(b));
gidsToShow.forEach((gid) => {
const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`;
@@ -249,13 +333,6 @@ function presetsSectionElForZone(zoneId) {
/** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : [];
if (!gids.length) {
const section = presetsSectionElForZone(zoneId);
if (typeof window.parseTabDeviceNames === 'function' && section) {
const fromDom = window.parseTabDeviceNames(section);
if (Array.isArray(fromDom) && fromDom.length) return fromDom;
}
}
if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids);
}
@@ -323,33 +400,12 @@ function createSequenceTileRow(sequenceId, sequenceDoc, zoneId, zoneDoc, allPres
button.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`;
const badge = document.createElement('span');
badge.textContent = 'SEQ';
badge.className = 'sequence-tile-badge';
badge.style.cssText =
'position:absolute;left:4px;top:4px;font-size:10px;font-weight:700;color:#fff;background:rgba(0,100,180,0.9);padding:2px 5px;border-radius:3px;pointer-events:none;z-index:2;';
button.style.position = 'relative';
button.appendChild(badge);
const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold';
label.className = 'pattern-button-label';
button.appendChild(label);
const sub = document.createElement('span');
sub.className = 'muted-text';
sub.style.cssText = 'display:block;font-size:0.8em;margin-top:0.2rem;';
const lanes = normalizeSequenceLanes(sequenceDoc);
const nLanes = lanes.filter((l) => l.length > 0).length || 1;
const nSteps = lanes.reduce((a, l) => a + l.length, 0);
const simRaw = sequenceDoc.simulated_bpm;
let sim = parseInt(String(simRaw != null ? simRaw : 120), 10);
if (!Number.isFinite(sim)) sim = 120;
sim = Math.min(300, Math.max(30, sim));
sub.textContent = `${nLanes} lane${nLanes === 1 ? '' : 's'} · ${nSteps} step${nSteps === 1 ? '' : 's'} · beats · ${sim} BPM sim`;
button.appendChild(sub);
button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => {
@@ -456,7 +512,7 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(tabData)
!window.zoneAllowsSequences(tabData, zoneId)
) {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return;
@@ -525,7 +581,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const zone = await zoneRes.json();
if (
typeof window.zoneAllowsSequences === 'function' &&
!window.zoneAllowsSequences(zone)
!window.zoneAllowsSequences(zone, zoneId)
) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
@@ -781,7 +837,7 @@ function renderSequenceStepRow(presetsMap, step) {
return row;
}
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) {
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex);
@@ -820,7 +876,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
head.appendChild(headBtns);
wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds));
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds, zoneGroupIds));
const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps';
@@ -893,6 +949,24 @@ async function openSequenceEditor(sequenceId, existing) {
const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq();
let zoneDoc = {};
const zoneIdForEditor = resolveZoneIdForPresetStripRefresh();
if (zoneIdForEditor) {
try {
const zr = await fetch(`/zones/${encodeURIComponent(zoneIdForEditor)}`, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
if (zr.ok) {
const zj = await zr.json();
if (zj && typeof zj === 'object' && !zj.error) zoneDoc = zj;
}
} catch (_) {
/* no zone context */
}
}
const zoneGroupIds = zoneGroupIdsFromDoc(zoneDoc);
let doc = existing;
if (sequenceEditorId) {
try {
@@ -926,11 +1000,11 @@ async function openSequenceEditor(sequenceId, existing) {
lanesHost.innerHTML = '';
if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap, zoneGroupIds));
} else {
lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length);
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap));
lanesHost.appendChild(renderSequenceLane(i, laneSteps, lg, presetsMap, groupsMap, zoneGroupIds));
});
}
refreshSequenceEditorLaneTitles();
@@ -1120,15 +1194,11 @@ async function loadSequencesModalList() {
});
}
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) {
try {
if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1');
else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY);
} catch (e) {
console.warn('[sequence] could not persist debug flag', e);
}
sequenceDebugEnabled = !!on;
console.log(seqDebugEnabled() ? 1 : 0);
};
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
@@ -1137,6 +1207,7 @@ window.addSequenceToTab = addSequenceToTab;
window.removeSequenceFromTab = removeSequenceFromTab;
document.addEventListener('DOMContentLoaded', () => {
void initSequenceSwitchToggle();
const btn = document.getElementById('sequences-btn');
const modal = document.getElementById('sequences-modal');
const closeBtn = document.getElementById('sequences-close-btn');

View File

@@ -106,7 +106,7 @@ header h1 {
font-weight: 600;
}
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
.header-end {
display: flex;
align-items: center;
@@ -199,20 +199,43 @@ header h1 {
.audio-top-indicator {
display: none;
flex-direction: column;
align-items: stretch;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
align-items: center;
gap: 0.35rem;
min-width: 9rem;
}
.audio-top-indicator-main {
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator .audio-top-beat-sync {
flex: 1;
min-width: 0;
}
.audio-top-beat-sync {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
min-height: 2.25rem;
padding: 0.3rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
cursor: pointer;
font-family: inherit;
text-align: left;
}
.audio-top-beat-sync:disabled {
cursor: default;
opacity: 0.85;
}
.audio-top-beat-sync:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #2a2a2a;
}
.audio-top-indicator-extra {
@@ -226,10 +249,6 @@ header h1 {
text-overflow: ellipsis;
}
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label {
font-size: 0.72rem;
color: #bdbdbd;
@@ -245,16 +264,46 @@ header h1 {
}
.audio-top-beat-readout {
font-size: 0.62rem;
font-size: 0.75rem;
color: #b0bec5;
line-height: 1.25;
max-width: 12rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
min-width: 0;
text-align: left;
min-width: 2rem;
text-align: right;
}
.audio-top-beat-readout:empty {
display: none;
}
.audio-top-beat-readout:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase {
font-size: 0.7rem;
color: #90a4ae;
line-height: 1.25;
white-space: nowrap;
}
.audio-top-bar-phase:empty {
display: none;
}
.audio-top-bar-phase:not(:empty)::before {
content: "·";
margin-right: 0.35rem;
color: #757575;
}
.audio-top-bar-phase.is-downbeat {
color: #ffab91;
}
.audio-top-indicator-subvalue {
@@ -264,16 +313,15 @@ header h1 {
text-align: right;
}
.audio-top-indicator.flash {
.audio-top-beat-sync.flash {
background-color: #ff5252;
border-color: #ff8a80;
}
.audio-top-indicator.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue,
.audio-top-indicator.flash .audio-top-indicator-extra,
.audio-top-indicator.flash .audio-top-beat-readout {
.audio-top-beat-sync.flash .audio-top-indicator-value,
.audio-top-beat-sync.flash .audio-top-indicator-label,
.audio-top-beat-sync.flash .audio-top-beat-readout,
.audio-top-beat-sync.flash .audio-top-beat-readout::before {
color: #fff;
}
@@ -333,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
.zones-container {
background-color: transparent;
padding: 0.35rem 0 0;
padding: 0;
flex: 0 0 auto;
width: 100%;
min-width: 0;
@@ -863,12 +911,41 @@ body.preset-ui-run .edit-mode-only {
min-width: 5rem;
}
#audio-modal .audio-settings-section {
margin-top: 1rem;
}
#audio-modal .audio-settings-section .audio-modal-beat-readout {
display: block;
width: 100%;
max-width: none;
}
.audio-modal-beat-readout {
flex: 1;
min-width: 10rem;
min-height: 2.25rem;
font-size: 0.85rem;
line-height: 1.35;
text-align: left;
text-align: center;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #252525;
padding: 0.35rem 0.65rem;
cursor: pointer;
font-family: inherit;
color: #b0bec5;
}
.audio-modal-beat-readout:disabled {
cursor: default;
opacity: 0.55;
}
.audio-modal-beat-readout:not(:disabled):hover {
border-color: #6a6a6a;
background-color: #333;
color: #e0e0e0;
}
.audio-hit-type-readout {
@@ -1003,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
white-space: normal;
}
.ui-mode-toggle--edit {
background-color: #4a3f8f;
border: 1px solid #7b6fd6;
.nav-slide-toggle-wrap {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.ui-mode-toggle--edit:hover {
.nav-slide-toggle-side-label {
font-size: 0.82rem;
color: #888;
user-select: none;
line-height: 1;
}
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
color: #e8e8e8;
font-weight: 500;
}
.nav-slide-toggle-switch {
position: relative;
width: 2.75rem;
height: 1.4rem;
padding: 0;
margin: 0;
color: inherit;
font: inherit;
appearance: none;
border: 1px solid #4a4a4a;
border-radius: 999px;
background-color: #2a2a2a;
cursor: pointer;
flex-shrink: 0;
}
.nav-slide-toggle-switch:hover {
border-color: #666;
}
.nav-slide-toggle-switch:focus-visible {
outline: 2px solid #7b6fd6;
outline-offset: 2px;
}
.nav-slide-toggle-track {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
}
.nav-slide-toggle-thumb {
position: absolute;
top: 50%;
left: 2px;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: #bdbdbd;
transform: translateY(-50%);
transition: left 0.2s ease, background-color 0.2s ease;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
background-color: #4a3f8f;
border-color: #7b6fd6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
background-color: #5a4f9f;
border-color: #8b7fe6;
}
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 1rem - 2px);
transform: translateY(-50%);
background-color: #e8e4ff;
}
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
display: flex;
justify-content: center;
align-items: center;
gap: 0.35rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #333;
flex-shrink: 1;
min-width: 0;
}
/* Preset select buttons inside the zone grid */
@@ -1261,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
display: none;
}
/* Beat/downbeat toggle lives in the mobile menu only */
#seq-switch-toggle-wrap {
display: none !important;
}
.main-menu-dropdown {
max-width: min(16rem, calc(100vw - 1rem));
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
font-size: 0.7rem;
flex-shrink: 1;
min-width: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
width: 3.6rem;
height: 1.25rem;
flex-shrink: 0;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
width: 0.9rem;
height: 0.9rem;
}
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
left: calc(100% - 0.9rem - 2px);
transform: translateY(-50%);
}
.header-menu-mobile {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.35rem;
margin-top: 0;
margin-left: 0;
}
.header-end {
@@ -1277,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
.header-end .audio-top-indicator {
min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0;
}
.header-end .audio-top-beat-sync {
padding: 0.2rem 0.4rem;
min-height: 2rem;
gap: 0.3rem;
}
.btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;

View File

@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
}
/**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
* Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
*/
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone);
@@ -321,7 +320,7 @@ async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: [];
if (!gids.length) {
return names.slice();
return [];
}
const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -509,28 +508,16 @@ function effectiveZoneContentKind(zoneDoc) {
return 'presets';
}
/** @returns {'presets' | 'sequences'} */
function editModalContentKindSelected() {
const radio = document.querySelector('input[name="edit-zone-content-kind"]:checked');
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
}
function activeZoneContentKind(zoneDoc) {
const modal = document.getElementById('edit-zone-modal');
if (modal && modal.classList.contains('active')) {
return editModalContentKindSelected();
}
return effectiveZoneContentKind(zoneDoc);
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc) {
return activeZoneContentKind(zoneDoc) === 'sequences';
function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'sequences';
}
function applyZoneContentKindEditModal(kind) {
@@ -607,7 +594,7 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) {
const zone = tabs[zoneId];
if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : '';
const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
const disp = zone.name || `Zone ${zoneId}`;
html += `
<button class="zone-button ${activeClass}"
@@ -1028,7 +1015,7 @@ async function refreshEditTabPresetsUi(zoneId) {
return;
}
const tabData = await tabRes.json();
if (!zoneAllowsPresets(tabData)) {
if (!zoneAllowsPresets(tabData, zoneId)) {
currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -1167,9 +1154,13 @@ async function openEditZoneModal(zoneId, zone) {
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.checked = radio.value === kind;
});
const typeLabel = document.getElementById('edit-zone-type-label');
if (typeLabel) {
typeLabel.textContent =
kind === 'sequences'
? 'Zone type: Sequences (set when the zone was created)'
: 'Zone type: Presets (set when the zone was created)';
}
if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(kind);
@@ -1180,24 +1171,39 @@ async function openEditZoneModal(zoneId, zone) {
}
// Update an existing zone (name, group list; devices come from groups only).
async function updateZone(zoneId, name, groupRows, contentKind) {
async function updateZone(zoneId, name, groupRows) {
try {
const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: [];
const ck =
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
let existing = {};
try {
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
headers: { Accept: 'application/json' },
});
if (cur.ok) {
const j = await cur.json();
if (j && typeof j === 'object') existing = j;
}
} catch (_) {
/* use empty existing */
}
const lockedKind = effectiveZoneContentKind(existing);
const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...existing,
name: name,
names: [],
group_ids: gids,
preset_group_ids: {},
content_kind: ck,
preset_group_ids:
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids
: {},
content_kind: lockedKind,
})
});
@@ -1206,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
// Reload tabs list
await loadZonesModal();
await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal
document.getElementById('edit-zone-modal').classList.remove('active');
return true;
@@ -1337,18 +1346,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
document.querySelectorAll('input[name="edit-zone-content-kind"]').forEach((radio) => {
radio.addEventListener('change', async () => {
applyZoneContentKindEditModal(editModalContentKindSelected());
const zoneId = document.getElementById('edit-zone-id')?.value;
if (!zoneId) return;
await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === 'function') {
await window.refreshEditTabSequencesUi(zoneId);
}
});
});
// Set up edit zone form
const editZoneForm = document.getElementById('edit-zone-form');
if (editZoneForm) {
@@ -1362,7 +1359,7 @@ document.addEventListener('DOMContentLoaded', () => {
const groupRows = window.__editTabGroupRows || [];
if (zoneId && name) {
await updateZone(zoneId, name, groupRows, editModalContentKindSelected());
await updateZone(zoneId, name, groupRows);
editZoneForm.reset();
}
});
@@ -1409,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
window.selectZone = selectZone;
// Export for use in other scripts
window.zonesManager = {
loadZones,

View File

@@ -9,18 +9,21 @@
<body>
<div class="app-container">
<header>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
<div class="audio-top-indicator-main">
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
</button>
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
</div>
<div id="audio-top-indicator" class="audio-top-indicator">
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
<span class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
</div>
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
</div>
<div class="header-actions">
<div class="header-brightness-control">
@@ -38,12 +41,20 @@
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</button>
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
</button>
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
</div>
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label>
@@ -60,10 +71,16 @@
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</button>
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
</div>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
</header>
<div class="main-content">
@@ -102,10 +119,7 @@
<input type="hidden" id="edit-zone-id">
<label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<div class="zone-content-kind-row muted-text">
<label><input type="radio" name="edit-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="edit-zone-content-kind" value="sequences"> Sequences</label>
</div>
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
@@ -348,6 +362,10 @@
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
<label style="display:block;margin-top:0.65rem;">
<input type="checkbox" id="sequence-editor-loop" checked>
Loop sequence (restart from the first step after the last)
</label>
</div>
<div id="sequence-editor-lanes"></div>
<div class="modal-actions preset-editor-modal-actions">
@@ -405,6 +423,12 @@
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div>
</div>
<div class="preset-editor-field" id="preset-reverse-group" hidden>
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
<input type="checkbox" id="preset-reverse-input">
Reverse direction (strip installed upside down)
</label>
</div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
<select id="preset-mode-input" class="preset-mode-input"></select>
@@ -619,22 +643,45 @@
<label>Current BPM</label>
<div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
</div>
</div>
<div class="form-group">
<label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</div>
<div class="form-group">
<label>Bar phase</label>
<div class="audio-bpm-row">
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
</div>
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
</div>
<div class="form-group">
<label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div>
<div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
<div class="settings-section audio-settings-section">
<h3>Audio settings</h3>
<div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
</div>
<div class="form-group">
<label>Beat sync</label>
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
</div>
<div class="form-group">
<label>Sequence alignment</label>
<div class="profiles-actions" style="flex-wrap: wrap;">
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
</div>
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
@@ -715,79 +762,14 @@
</div>
</div>
<!-- LED Tool Modal -->
<!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal">
<div class="modal-content">
<h2>LED Tool (USB)</h2>
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p>
<div id="led-tool-message" class="message" style="margin-bottom: 0.75rem;"></div>
<form id="led-tool-form">
<div class="form-group">
<label for="led-tool-port">Serial port</label>
<div class="profiles-actions" style="gap: 0.5rem;">
<select id="led-tool-port" required style="flex:1;">
<option value="">Select a serial port</option>
</select>
<button type="button" class="btn btn-secondary" id="led-tool-refresh-ports-btn">Refresh</button>
</div>
</div>
<div class="form-group">
<label for="led-tool-name">Name</label>
<input type="text" id="led-tool-name" placeholder="led-abcdef123456">
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-num-leds">Num LEDs</label>
<input type="number" id="led-tool-num-leds" min="1" max="5000" placeholder="60">
</div>
<div class="preset-editor-field">
<label for="led-tool-led-pin">LED pin</label>
<input type="number" id="led-tool-led-pin" min="0" max="48" placeholder="4">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-brightness">Brightness</label>
<input type="number" id="led-tool-brightness" min="0" max="255" placeholder="255">
</div>
<div class="preset-editor-field">
<label for="led-tool-wifi-channel">WiFi channel</label>
<input type="number" id="led-tool-wifi-channel" min="1" max="11" placeholder="6">
</div>
</div>
<div class="profiles-actions">
<div class="preset-editor-field">
<label for="led-tool-transport">Transport</label>
<select id="led-tool-transport">
<option value="">(no change)</option>
<option value="espnow">espnow</option>
<option value="wifi">wifi</option>
</select>
</div>
<div class="preset-editor-field">
<label for="led-tool-default">Default preset</label>
<input type="text" id="led-tool-default" placeholder="on">
</div>
</div>
<div class="form-group">
<label for="led-tool-ssid">SSID</label>
<input type="text" id="led-tool-ssid" placeholder="Your WiFi SSID">
</div>
<div class="form-group">
<label for="led-tool-password">WiFi password</label>
<input type="password" id="led-tool-password" placeholder="WiFi password">
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="led-tool-read-btn">Read</button>
<button type="button" class="btn btn-secondary" id="led-tool-reset-btn">Reset</button>
<button type="submit" class="btn btn-primary">Apply via USB</button>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
</form>
<label for="led-tool-output" style="margin-top:0.5rem; display:block;">Command output</label>
<textarea id="led-tool-output" rows="12" readonly style="width:100%; font-family:monospace; resize:vertical;"></textarea>
<div class="modal-content" style="max-width: 960px; width: 95vw;">
<div class="modal-actions" style="margin-bottom: 0.5rem;">
<h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" style="width:100%;height:min(75vh,720px);border:1px solid #4a4a4a;border-radius:4px;background:#0b1020;"></iframe>
</div>
</div>
@@ -805,5 +787,6 @@
<script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/audio.js"></script>
<script src="/static/numpad.js"></script>
</body>
</html>

View File

@@ -261,7 +261,7 @@
return;
}
try {
const response = await fetch('/settings/settings', {
const response = await fetch('/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wifi_channel: wifiChannel }),

View File

@@ -4,6 +4,12 @@ import os
import queue
import threading
import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
class AudioBeatDetector:
@@ -13,6 +19,11 @@ class AudioBeatDetector:
self._stream = None
self._running = False
self._stop_event = threading.Event()
self._runtime = None
self._pending_reset = False
self._holdover_thread: threading.Thread | None = None
self._holdover_stop = threading.Event()
self._holdover_active = False
self._status = {
"running": False,
"bpm": None,
@@ -20,6 +31,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": None,
}
@@ -100,6 +116,11 @@ class AudioBeatDetector:
"beat_seq": 0,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"beats_per_bar": 4,
"is_downbeat": False,
"phase_confidence": 0.0,
"bar_phase_readout": "1/4",
"error": None,
"device": device,
}
@@ -111,6 +132,7 @@ class AudioBeatDetector:
self._thread.start()
def stop(self):
self._stop_bpm_holdover()
with self._lock:
self._stop_event.set()
t = self._thread
@@ -139,11 +161,159 @@ class AudioBeatDetector:
self._running = False
self._thread = None
self._stream = None
self._pending_reset = False
self._status["running"] = False
def status(self):
with self._lock:
return dict(self._status)
st = dict(self._status)
holdover = self._holdover_active
last = st.get("last_beat_ts")
if st.get("running") and last is not None and not holdover:
try:
if (time.time() - float(last)) > 4.0:
st["bpm"] = None
except (TypeError, ValueError):
pass
return st
def _apply_tracking_reset_status(self) -> None:
"""Refresh published status after a tracking reset (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
self._status.update(
{
"running": True,
"beat_type": "unknown",
"beat_type_confidence": 0.0,
"bar_beat": 1,
"is_downbeat": True,
"phase_confidence": 0.0,
"bar_phase_readout": f"1/{bpb}",
}
)
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
try:
v = float(bpm)
except (TypeError, ValueError):
return None
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
return None
return v
def _holdover_interval_s(self, bpm: float) -> float:
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
def _stop_bpm_holdover(self) -> None:
with self._lock:
self._holdover_active = False
self._holdover_stop.set()
t = self._holdover_thread
if t and t.is_alive() and t is not threading.current_thread():
t.join(timeout=2.0)
with self._lock:
if self._holdover_thread is t:
self._holdover_thread = None
def _advance_holdover_bar_phase_locked(self) -> dict:
"""Advance bar phase for one synthetic beat (lock must be held)."""
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
prev = int(self._status.get("bar_beat") or 1)
bar_beat = (prev % bpb) + 1
is_downbeat = bar_beat == 1
bar_readout = f"{bar_beat}/{bpb}"
self._status["bar_beat"] = bar_beat
self._status["is_downbeat"] = is_downbeat
self._status["bar_phase_readout"] = bar_readout
return {
"bar_beat": bar_beat,
"beats_per_bar": bpb,
"is_downbeat": is_downbeat,
"bar_phase_readout": bar_readout,
}
def _emit_holdover_beat(self, bpm: float) -> None:
now = time.time()
with self._lock:
if not self._running or not self._holdover_active:
return
self._advance_holdover_bar_phase_locked()
self._status["last_beat_ts"] = now
self._status["bpm"] = float(bpm)
self._status["beat_type"] = "holdover"
self._status["beat_type_confidence"] = 0.0
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
try:
from util import sequence_playback as seq_pb
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] holdover beat queue: {e}")
def _holdover_loop(self, bpm: float, started_at: float) -> None:
interval = self._holdover_interval_s(bpm)
while not self._holdover_stop.is_set():
with self._lock:
if not self._running or not self._holdover_active:
return
if (time.time() - started_at) > _HOLDOVER_MAX_S:
self._holdover_active = False
return
last = self._status.get("last_beat_ts")
if last is not None:
try:
delay = max(0.02, float(last) + interval - time.time())
except (TypeError, ValueError):
delay = interval
else:
delay = interval
if self._holdover_stop.wait(delay):
return
self._emit_holdover_beat(bpm)
def _start_bpm_holdover(self, bpm: float) -> None:
bpm_v = self._clamp_holdover_bpm(bpm)
if bpm_v is None:
return
self._stop_bpm_holdover()
self._holdover_stop.clear()
started_at = time.time()
with self._lock:
self._holdover_active = True
self._holdover_thread = threading.Thread(
target=self._holdover_loop,
args=(bpm_v, started_at),
name="audio-bpm-holdover",
daemon=True,
)
t = self._holdover_thread
t.start()
def _process_pending_reset(self, runtime) -> None:
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
with self._lock:
if not self._pending_reset:
return
self._pending_reset = False
try:
runtime.reset_state()
with self._lock:
self._apply_tracking_reset_status()
except Exception as e:
print(f"[audio] pending reset: {e}")
def reset_tracking(self) -> bool:
"""Clear detector tempo history without stopping the input stream."""
holdover_bpm = None
with self._lock:
if not self._running or self._runtime is None:
return False
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
self._pending_reset = True
self._apply_tracking_reset_status()
if holdover_bpm is not None:
self._start_bpm_holdover(holdover_bpm)
return True
def _set_error(self, msg):
print(f"[audio] {msg}")
@@ -152,7 +322,28 @@ class AudioBeatDetector:
self._status["running"] = False
self._running = False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
def anchor_bar_phase(self) -> bool:
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
with self._lock:
rt = self._runtime
if rt is None:
return False
try:
rt.anchor_bar_phase(time.time())
with self._lock:
self._status["bar_beat"] = 1
self._status["is_downbeat"] = True
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
self._status["phase_confidence"] = max(
float(self._status.get("phase_confidence") or 0.0), 0.85
)
return True
except Exception as e:
print(f"[audio] anchor_bar_phase: {e}")
return False
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
self._stop_bpm_holdover()
now = time.time()
with self._lock:
self._status["last_beat_ts"] = now
@@ -160,6 +351,16 @@ class AudioBeatDetector:
self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
if phase_fields.get("bar_beat") is not None:
self._status["bar_beat"] = int(phase_fields["bar_beat"])
if phase_fields.get("beats_per_bar") is not None:
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
if phase_fields.get("is_downbeat") is not None:
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
if phase_fields.get("phase_confidence") is not None:
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
if phase_fields.get("bar_phase_readout"):
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
try:
from util import sequence_playback as seq_pb
@@ -210,15 +411,17 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=85.0,
min_ioi_ms=100.0,
bpm_window=8,
post_url="",
aubio_method="default",
aubio_threshold=0.12,
silence_gate_db=-58.0,
aubio_threshold=0.14,
beats_per_bar=4,
)
runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate)
with self._lock:
self._runtime = runtime
hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64)
@@ -243,10 +446,12 @@ class AudioBeatDetector:
stream.start()
try:
while not self._stop_event.is_set():
self._process_pending_reset(runtime)
try:
frame = audio_q.get(timeout=0.1)
except queue.Empty:
continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size:
frame = frame[:hop_size]
@@ -260,6 +465,11 @@ class AudioBeatDetector:
bpm,
beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0),
bar_beat=event.get("bar_beat"),
beats_per_bar=event.get("beats_per_bar"),
is_downbeat=event.get("is_downbeat"),
phase_confidence=event.get("phase_confidence"),
bar_phase_readout=event.get("bar_phase_readout"),
)
finally:
try:
@@ -280,6 +490,7 @@ class AudioBeatDetector:
with self._lock:
self._running = False
self._status["running"] = False
self._runtime = None
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
@@ -299,3 +510,25 @@ def shared_beat_detector_running():
return bool(d.status().get("running"))
except Exception:
return False
def shared_beat_status_snapshot() -> dict:
"""Thread-safe copy of live detector status, or {} if audio is off."""
d = _shared_beat_detector
if d is None:
return {}
try:
return dict(d.status())
except Exception:
return {}
def anchor_shared_bar_phase() -> bool:
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
d = _shared_beat_detector
if d is None:
return False
try:
return bool(d.anchor_bar_phase())
except Exception:
return False

View File

@@ -30,20 +30,64 @@ def read_audio_run_state() -> Dict[str, Any]:
except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None}
if not isinstance(raw, dict):
return {"enabled": False, "device": None}
return {
"enabled": False,
"device": None,
"device_override": "",
"device_select": "",
}
enabled = bool(raw.get("enabled"))
dev = raw.get("device", None)
return {"enabled": enabled, "device": dev}
return {
"enabled": enabled,
"device": dev,
"device_override": str(raw.get("device_override") or ""),
"device_select": str(raw.get("device_select") or ""),
}
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
def write_audio_run_state(
*,
enabled: bool,
device: Any = None,
device_override: str | None = None,
device_select: str | None = None,
) -> None:
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
path = _db_path()
prev = read_audio_run_state()
if enabled:
data = {"enabled": True, "device": device}
data = {
"enabled": True,
"device": device,
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
if device_select is None and device is not None:
data["device_select"] = str(device)
else:
data = {"enabled": False, "device": prev.get("device")}
data = {
"enabled": False,
"device": prev.get("device"),
"device_override": (
str(device_override)
if device_override is not None
else str(prev.get("device_override") or "")
),
"device_select": (
str(device_select)
if device_select is not None
else str(prev.get("device_select") or "")
),
}
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:

View File

@@ -299,6 +299,10 @@ def _apply_manual_beat_route_standalone_overlay(
return
names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock:
if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
return
_lane_manual[-1] = {
"device_names": names,
"wire_preset_id": str(wire_preset_id).strip(),
@@ -350,6 +354,11 @@ def set_sequence_manual_lane_route(
"manual_beat_n": mn,
"beat_counter": bc,
}
overlay = _lane_manual.get(-1)
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
):
_lane_manual.pop(-1, None)
_sync_public_beat_route_from_lane_table()
@@ -362,6 +371,49 @@ def clear_sequence_manual_lane_route(lane_index: int) -> None:
_sync_public_beat_route_from_lane_table()
def _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
return names, str(wire_preset_id or "").strip()
def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
"""True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
key = _lane_route_targets_key(device_names, wire_preset_id)
for lane_key, entry in _lane_manual.items():
if not isinstance(lane_key, int) or lane_key < 0:
continue
other = _lane_route_targets_key(
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
)
if other == key:
return True
return False
def mark_manual_select_sent_for_targets(
device_names: List[str], wire_preset_id: str
) -> None:
"""A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
key = _lane_route_targets_key(device_names, wire_preset_id)
with _route_lock:
for entry in _lane_manual.values():
if not isinstance(entry, dict):
continue
other = _lane_route_targets_key(
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
)
if other == key:
entry["suppress_next_notify"] = True
def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
"""A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
with _route_lock:
e = _lane_manual.get(lane_index)
if e is not None:
e["suppress_next_notify"] = True
def sync_beat_route_from_push_sequence(
sequence: List[Any],
target_macs: Optional[List[str]] = None,
@@ -438,6 +490,7 @@ def sync_beat_route_from_push_sequence(
)
else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
return
wire_id, body = _single_manual_wire_preset(merged_presets)
@@ -547,6 +600,7 @@ def notify_beat_detected() -> None:
if not _lane_manual:
return
work = []
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
for key in sorted(_lane_manual.keys()):
e = _lane_manual[key]
names = e.get("device_names") or []
@@ -555,6 +609,8 @@ def notify_beat_detected() -> None:
pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern):
continue
if e.pop("suppress_next_notify", False):
continue
try:
n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError):
@@ -564,7 +620,12 @@ def notify_beat_detected() -> None:
c = int(e["beat_counter"])
if (c - 1) % n != 0:
continue
work.append((list(names), str(e.get("wire_preset_id") or "2")))
wire = str(e.get("wire_preset_id") or "2")
target_key = _lane_route_targets_key(names, wire)
if target_key in seen_targets:
continue
seen_targets.add(target_key)
work.append((list(names), wire))
if work:
_preset_session_beats += 1
if not work:

View File

@@ -24,6 +24,16 @@ _sim_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock()
_pending_play: Optional[Dict[str, Any]] = None
_pending_play_lock = threading.Lock()
_pending_beat_task: Optional[asyncio.Task] = None
_pending_beat_token = 0
_last_thread_beat_phase: Dict[str, Any] = {
"is_downbeat": True,
"bar_beat": 1,
}
_sim_beat_counter = 0
def _norm_mac(raw: Any) -> Optional[str]:
from models.device import normalize_mac
@@ -68,8 +78,13 @@ def _normalize_sequence_lanes(doc: Dict[str, Any]) -> List[List[Dict[str, Any]]]
def _group_ids_for_lane_step(
sequence_doc: Dict[str, Any], step: Dict[str, Any], lane_index: int, num_lanes: int
sequence_doc: Dict[str, Any],
step: Dict[str, Any],
lane_index: int,
num_lanes: int,
zone_doc: Optional[Dict[str, Any]] = None,
) -> List[str]:
_ = zone_doc
lgs = sequence_doc.get("lanes_group_ids")
if isinstance(lgs, list) and lane_index < len(lgs):
for_lane = lgs[lane_index]
@@ -224,7 +239,7 @@ def _resolve_step_device_names(
) -> List[str]:
z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
if not step_group_ids:
return list(z_names)
return []
zone_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
zone_name_by_mac: Dict[str, str] = {}
for i, m in enumerate(z_macs):
@@ -263,12 +278,29 @@ def _lane_has_non_empty_lanes_group_ids(sequence_doc: Dict[str, Any], lane_index
return any(x is not None and str(x).strip() for x in for_lane)
def _partition_devices_for_lane(
num_lanes: int,
*,
lane_has_own_groups: bool,
step_group_ids: List[str],
) -> bool:
"""Split zone devices across lanes only when lanes lack explicit group targeting."""
if num_lanes <= 1:
return False
if lane_has_own_groups:
return False
# No lane groups (whole zone): every lane uses all zone / zone-group devices.
if not step_group_ids:
return False
return False
def _split_device_names_for_lane(
all_names: List[str],
lane_index: int,
num_lanes: int,
*,
partition_shared_zone: bool = True,
partition_shared_zone: bool = False,
) -> List[str]:
names = [n for n in all_names if n and str(n).strip()]
if num_lanes <= 1 or not partition_shared_zone:
@@ -299,21 +331,6 @@ def _resolve_colors_with_palette_refs(
return out
def _ordered_unique_preset_ids_from_lanes(lanes: List[List[Dict[str, Any]]]) -> List[str]:
seen: set = set()
out: List[str] = []
for lane in lanes:
for step in lane:
if not isinstance(step, dict):
continue
pid = str(step.get("preset_id") or "").strip()
if not pid or pid in seen:
continue
seen.add(pid)
out.append(pid)
return out
def _display_preset_for_step(
preset_id: str,
presets_map: Dict[str, Any],
@@ -348,6 +365,134 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
return inner
def _ordered_unique_preset_ids_in_lane(lane: List[Dict[str, Any]]) -> List[str]:
seen: set = set()
out: List[str] = []
for step in lane:
if not isinstance(step, dict):
continue
pid = str(step.get("preset_id") or "").strip()
if not pid or pid in seen:
continue
seen.add(pid)
out.append(pid)
return out
def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str]:
"""Device names for one lane (lane groups / whole zone), after lane partition."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
zone_doc: Dict[str, Any] = ctx["zone_doc"]
devices = ctx["devices"]
groups = ctx["groups"]
num_lanes = int(ctx["num_lanes"])
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane:
return []
gids = _group_ids_for_lane_step(
sequence_doc, lane[0], lane_index, num_lanes, zone_doc=zone_doc
)
device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
)
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
return _split_device_names_for_lane(
device_names,
lane_index,
num_lanes,
partition_shared_zone=_partition_devices_for_lane(
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
),
)
def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
"""All preset wire bodies for one lane, keyed by preset id."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
inner_by_wire: Dict[str, Any] = {}
for pid in _ordered_unique_preset_ids_in_lane(lane):
disp = _display_preset_for_step(pid, presets_map, palette_colors)
if not disp:
continue
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
return inner_by_wire
async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
"""Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
if not lane_steps:
return
inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
if not inner_by_wire:
return
step0 = lane_steps[0]
preset_id = str(step0.get("preset_id") or "").strip()
if not preset_id:
return
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
device_names = _resolve_lane_device_names(lane_index, ctx)
macs = _device_names_to_macs(device_names, ctx["devices"])
if not macs:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
devices_model = ctx["devices"]
wire = str(preset_id)
auto = _coerce_auto(display_preset)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
delay_s = 0.05
for mac in macs:
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
if sel:
body["select"] = sel
msg = json.dumps(body, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
if auto:
clear_sequence_manual_lane_route(lane_index)
else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
mark_sequence_manual_lane_select_sent(lane_index)
async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
"""One-shot preset upload + first-step select per lane (to each lane's groups)."""
for i in range(int(ctx["num_lanes"])):
await _prime_lane(i, ctx)
ctx["_presets_delivered"] = True
ctx["_sequence_primed"] = True
def _parse_zone_brightness_value(zone_doc: Any) -> int:
"""Zone slider value stored on the zone row (0255); default 255 if unset."""
from util.brightness_combine import clamp255
@@ -363,37 +508,33 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
return 255
def _inner_wire_b_with_sequence_zone_brightness(
inner: Dict[str, Any],
zone_doc: Dict[str, Any],
*,
target_mac: Optional[str],
settings_obj: Any,
groups_model: Any,
devices_model: Any,
) -> Dict[str, Any]:
"""Combine preset wire ``b`` with zone brightness (and global/group/device when ``target_mac`` is set)."""
from util.brightness_combine import (
clamp255,
multiply_brightness_factors,
effective_brightness_for_mac,
)
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
from models.transport import get_current_sender
from util.brightness_combine import effective_brightness_for_mac
from util.driver_delivery import deliver_json_messages
out = dict(inner)
base = clamp255(out.get("b", 127))
sender = get_current_sender()
if not sender:
return
macs = _union_macs_for_sequence(ctx)
if not macs:
return
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
zb = _parse_zone_brightness_value(zone_doc)
if target_mac and settings_obj is not None and groups_model is not None and devices_model is not None:
settings_obj = ctx.get("settings")
groups_model = ctx.get("groups")
devices_model = ctx.get("devices")
for mac in macs:
eff = effective_brightness_for_mac(
settings_obj,
groups_model,
devices_model,
target_mac,
mac,
zone_brightness=zb,
)
out["b"] = multiply_brightness_factors([base, eff])
else:
out["b"] = multiply_brightness_factors([base, zb])
return out
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
@@ -431,16 +572,19 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
for step in lane:
if not isinstance(step, dict):
continue
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
gids = _group_ids_for_lane_step(
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
)
device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
)
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
device_names = _split_device_names_for_lane(
device_names,
lane_index,
num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(
sequence_doc, lane_index
partition_shared_zone=_partition_devices_for_lane(
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
),
)
if gids and not device_names:
@@ -449,58 +593,24 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
if m and m not in seen:
seen.add(m)
out.append(m)
if out:
return out
_, z_macs = _compute_zone_targets(zone_doc, devices, groups)
return list(z_macs)
return out
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]:
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
presets_map: Dict[str, Any] = ctx["presets_map"]
palette_colors: List[Any] = ctx["palette_colors"]
inner_by_wire: Dict[str, Any] = {}
for pid in _ordered_unique_preset_ids_from_lanes(lanes):
disp = _display_preset_for_step(pid, presets_map, palette_colors)
if not disp:
continue
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
return inner_by_wire
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
"""Push all preset definitions used in the sequence once; step advances use select (auto) only."""
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
inner_by_wire = _build_sequence_wire_presets_map(ctx)
ctx["_sequence_wire_presets"] = inner_by_wire
if not inner_by_wire:
return
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
macs = _union_macs_for_sequence(ctx)
if not macs:
return
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
settings_obj = ctx.get("settings")
groups_model = ctx.get("groups")
devices_model = ctx.get("devices")
delay_s = 0.05
for mac in macs:
adjusted: Dict[str, Any] = {}
for wire_pid, inner in inner_by_wire.items():
adjusted[wire_pid] = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_doc,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices_model,
)
msg = json.dumps({"v": "1", "presets": adjusted}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
if isinstance(raw, bool):
return raw
if raw is None:
return True
if isinstance(raw, int):
return raw != 0
if isinstance(raw, str):
lo = raw.strip().lower()
if lo in ("false", "0", "no", "off"):
return False
if lo in ("true", "1", "yes", "on"):
return True
return True
def _coerce_auto(preset: Dict[str, Any]) -> bool:
@@ -533,119 +643,17 @@ def _load_palette_colors(profile_id: str) -> List[Any]:
return Palette().read(str(pid)) or []
async def _deliver_preset_for_devices(
preset_id: str,
preset_doc: Dict[str, Any],
device_names: List[str],
devices: Any,
*,
lane_index: Optional[int] = None,
zone_doc: Optional[Dict[str, Any]] = None,
settings_obj: Any = None,
groups_model: Any = None,
) -> None:
from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages
from util.beat_driver_route import sync_beat_route_from_push_sequence
from util.espnow_message import build_preset_dict
sender = get_current_sender()
if not sender:
raise RuntimeError("Transport not configured")
macs: List[str] = []
seen: set = set()
for nm in device_names:
key = str(nm).strip()
if not key:
continue
m = None
for did in devices.list():
doc = devices.read(did) or {}
if str(doc.get("name") or "").strip() == key:
m = _norm_mac(did)
break
if not m and key.startswith("led-"):
m = _norm_mac(key[4:])
if m and m not in seen:
seen.add(m)
macs.append(m)
if not macs:
return
body = dict(preset_doc)
auto = _coerce_auto(body)
inner_base = build_preset_dict(body)
mb = body.get("manual_beat_n", body.get("manualBeatN"))
if mb is not None:
try:
n = int(mb)
if 1 <= n <= 64:
inner_base["manual_beat_n"] = n
except (TypeError, ValueError):
pass
wire = str(preset_id)
zone_use = zone_doc if isinstance(zone_doc, dict) else {}
sel_append: Optional[Dict[str, Any]] = None
if auto and device_names:
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if sel:
sel_append = {"v": "1", "select": sel}
for mac in macs:
inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=mac,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
if sel_append:
seq_list.append(dict(sel_append))
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
await deliver_json_messages(sender, messages, [mac], devices, delay_s=0.05)
if not auto:
manual_inner = _inner_wire_b_with_sequence_zone_brightness(
inner_base,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=settings_obj,
groups_model=groups_model,
devices_model=devices,
)
if lane_index is not None:
from util.beat_driver_route import set_sequence_manual_lane_route
set_sequence_manual_lane_route(lane_index, device_names, wire, manual_inner)
else:
seq_one = [{"v": "1", "presets": {wire: manual_inner}}]
if sel_append:
seq_one.append(dict(sel_append))
sync_beat_route_from_push_sequence(
seq_one, target_macs=macs, preserve_parallel_lane_routes=True
)
async def _send_lane(
lane_index: int,
st: Dict[str, Any],
ctx: Dict[str, Any],
) -> None:
"""Apply the current step (select or manual route). Presets must already be on devices."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
presets_map: Dict[str, Any] = ctx["presets_map"]
zone_doc: Dict[str, Any] = ctx["zone_doc"]
devices = ctx["devices"]
groups = ctx["groups"]
palette_colors: List[Any] = ctx["palette_colors"]
num_lanes = ctx["num_lanes"]
devices = ctx["devices"]
if st.get("done"):
return
@@ -660,15 +668,22 @@ async def _send_lane(
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset:
return
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
num_lanes = int(ctx["num_lanes"])
zone_doc = ctx["zone_doc"]
gids = _group_ids_for_lane_step(
sequence_doc, step, lane_index, num_lanes, zone_doc=zone_doc
)
device_names = _resolve_step_device_names(
zone_doc, gids, devices, ctx["groups"], sequence_doc=sequence_doc
)
lane_own = _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index)
device_names = _split_device_names_for_lane(
device_names,
lane_index,
num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
partition_shared_zone=_partition_devices_for_lane(
num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
),
)
if gids and not device_names:
return
@@ -676,6 +691,7 @@ async def _send_lane(
from models.transport import get_current_sender
from util.beat_driver_route import (
clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route,
)
from util.driver_delivery import deliver_json_messages
@@ -688,44 +704,29 @@ async def _send_lane(
if not macs:
return
bulk = ctx.get("_sequence_wire_presets")
if isinstance(bulk, dict) and bulk:
auto = _coerce_auto(display_preset)
wire = str(preset_id)
auto = _coerce_auto(display_preset)
if auto:
clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if not sel:
return
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
else:
inner = _preset_inner_from_display_preset(display_preset)
zone_use = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
inner = _inner_wire_b_with_sequence_zone_brightness(
inner,
zone_use,
target_mac=macs[0] if len(macs) == 1 else None,
settings_obj=ctx.get("settings"),
groups_model=ctx.get("groups"),
devices_model=devices,
)
wire = str(preset_id)
if auto:
clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if not sel:
return
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
sel: Dict[str, Any] = {}
for n in device_names:
if n:
sel[str(n)] = [wire]
if sel:
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
else:
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
return
await _deliver_preset_for_devices(
preset_id,
display_preset,
device_names,
devices,
lane_index=lane_index,
zone_doc=zone_doc,
settings_obj=ctx.get("settings"),
groups_model=groups,
)
mark_sequence_manual_lane_select_sent(lane_index)
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
@@ -745,7 +746,7 @@ def _build_ctx(
) -> Optional[Dict[str, Any]]:
from models.device import Device
from models.group import Group
from settings import Settings
from settings import get_settings
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
if not lanes:
@@ -764,9 +765,9 @@ def _build_ctx(
"presets_map": presets_map,
"devices": devices,
"groups": groups,
"settings": Settings(),
"settings": get_settings(),
"palette_colors": palette_colors,
"loop": True,
"loop": _coerce_loop(sequence_doc),
"advance_mode": "beats",
}
@@ -897,7 +898,294 @@ async def process_active_beat_advance() -> None:
else:
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
if all(s.get("done") for s in lane_states):
stop()
await stop_playback(clear_devices=True)
return
async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
"""Stop beat routing and clear driver presets for devices used by this sequence run."""
from models.transport import get_current_sender
from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
from util.driver_delivery import deliver_json_messages
num_lanes = int(ctx.get("num_lanes") or 0)
for i in range(num_lanes):
clear_sequence_manual_lane_route(i)
update_beat_route({"enabled": False})
sender = get_current_sender()
if not sender:
return
devices = ctx.get("devices")
macs = _union_macs_for_sequence(ctx)
if not macs:
return
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
def _halt_playback_state() -> Optional[Dict[str, Any]]:
"""Drop active run state and cancel simulated beats; return the previous ctx."""
global _beat_run, _sim_beat_task, _sim_beat_token
ctx: Optional[Dict[str, Any]] = None
with _beat_run_lock:
ctx = _beat_run
_beat_run = None
_sim_beat_token += 1
st = _sim_beat_task
_sim_beat_task = None
if st and not st.done():
st.cancel()
return ctx
async def stop_playback(*, clear_devices: bool = True) -> None:
"""Stop sequence playback; optionally clear presets on targeted devices."""
clear_pending_play()
ctx = _halt_playback_state()
if clear_devices and ctx:
await _clear_devices_after_sequence(ctx)
def apply_beat_phase_sync(ctx: Dict[str, Any], mode: str) -> Tuple[bool, bool]:
"""Align beat counters to music.
``step`` (default): beat 1 of the current step on the next counted beat.
``pass``: restart from step 1 of the sequence pass and re-apply presets.
Returns ``(ok, resend_lanes)`` — caller should ``await _send_all_lanes(ctx)`` when resend is true.
"""
if not ctx:
return False, False
mode_norm = str(mode or "step").strip().lower()
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
if mode_norm in ("pass", "sequence", "restart"):
for st in lane_states:
st["stepIdx"] = 0
st["beatCount"] = 0
st["done"] = False
ctx["sequence_loop_beat"] = 0
return True, True
for st in lane_states:
if not st.get("done"):
st["beatCount"] = 0
return True, False
async def sync_beat_phase(mode: str = "step") -> bool:
"""Public entry: align active sequence playback to a musical phase."""
with _beat_run_lock:
ctx = _beat_run
if not ctx:
return False
ok, resend = apply_beat_phase_sync(ctx, mode)
if not ok:
return False
if resend:
await _send_all_lanes(ctx)
return True
def _drain_beat_queue() -> None:
try:
while True:
_thread_beat_queue.get_nowait()
except queue.Empty:
pass
def _reset_beat_side_effects() -> None:
"""Clear manual routes and queued beats so startup cannot select before presets land."""
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False})
_drain_beat_queue()
def _sequence_switch_wait_from_settings() -> str:
try:
from settings import get_settings
raw = get_settings().get("sequence_switch_wait", "beat")
mode = _normalize_wait_for({"wait_for": raw}) or "beat"
if mode == "phrase":
return "beat"
return mode
except Exception:
return "beat"
def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
"""``beat`` | ``downbeat`` | None (immediate)."""
if not isinstance(play_options, dict):
return None
raw = play_options.get("wait_for")
if raw is None:
raw = play_options.get("start_on")
if raw is None:
return None
s = str(raw).strip().lower()
if s in ("beat", "next_beat"):
return "beat"
if s in ("downbeat", "next_downbeat"):
return "downbeat"
return None
def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if not isinstance(play_options, dict):
return play_options
out = dict(play_options)
out.pop("wait_for", None)
out.pop("start_on", None)
return out
def _cancel_pending_beat_waiter() -> None:
global _pending_beat_task, _pending_beat_token
_pending_beat_token += 1
t = _pending_beat_task
_pending_beat_task = None
if t and not t.done():
t.cancel()
def clear_pending_play() -> None:
"""Drop a queued sequence start (e.g. user stop)."""
global _pending_play
with _pending_play_lock:
_pending_play = None
_cancel_pending_beat_waiter()
def pending_play_status() -> Dict[str, Any]:
with _pending_play_lock:
p = _pending_play
if not p:
return {"pending": False}
return {
"pending": True,
"wait_for": p.get("wait_for"),
"sequence_id": p.get("sequence_id"),
"zone_id": p.get("zone_id"),
}
def _beat_phase_from_sources() -> Dict[str, Any]:
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
st = ad_mod.shared_beat_status_snapshot()
if st:
return dict(st)
return dict(_last_thread_beat_phase)
def _beat_is_downbeat_from_sources() -> bool:
return bool(_beat_phase_from_sources().get("is_downbeat"))
def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
global _sim_beat_counter, _last_thread_beat_phase
bpb = max(1, int(beats_per_bar))
_sim_beat_counter += 1
bar_beat = ((_sim_beat_counter - 1) % bpb) + 1
is_downbeat = bar_beat == 1
_last_thread_beat_phase = {
"bar_beat": bar_beat,
"is_downbeat": is_downbeat,
}
def _queue_pending_start(
zone_id: str,
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]],
wait_for: str,
*,
bpm: float,
) -> None:
global _pending_play
clear_pending_play()
with _pending_play_lock:
_pending_play = {
"zone_id": str(zone_id),
"sequence_id": str(sequence_id),
"profile_id": str(profile_id),
"play_options": _play_options_without_wait(play_options),
"wait_for": wait_for,
}
_ensure_pending_beat_waiter(bpm)
def _ensure_pending_beat_waiter(bpm: float) -> None:
"""When nothing is playing and audio is off, emit synthetic beats until pending starts."""
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
return
with _beat_run_lock:
if _beat_run:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
global _pending_beat_task, _pending_beat_token
t = _pending_beat_task
if t and not t.done():
t.cancel()
_pending_beat_token += 1
my_tok = _pending_beat_token
_pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))
async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
while True:
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
await asyncio.sleep(interval)
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
_mark_simulated_beat_phase()
push_thread_beat()
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
global _pending_play
with _pending_play_lock:
pending = _pending_play
if not pending:
return False
wait_for = str(pending.get("wait_for") or "beat").strip().lower()
if wait_for == "downbeat" and not is_downbeat:
return False
_pending_play = None
_cancel_pending_beat_waiter()
await _start_immediate(
pending["zone_id"],
pending["sequence_id"],
pending["profile_id"],
pending.get("play_options"),
)
return True
def stop() -> None:
"""Stop server playback state without sending device clear (e.g. before starting another run)."""
clear_pending_play()
_halt_playback_state()
_reset_beat_side_effects()
def push_thread_beat() -> None:
@@ -920,6 +1208,12 @@ async def beat_consumer_loop() -> None:
from util.beat_driver_route import notify_beat_detected
for _ in range(n):
phase = _beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
try:
await _try_consume_pending_play(is_downbeat=is_down)
except Exception as e:
print(f"[sequence-playback] pending start: {e}")
try:
await process_active_beat_advance()
except Exception as e:
@@ -981,20 +1275,10 @@ async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -
return
if ad_mod.shared_beat_detector_running():
continue
_mark_simulated_beat_phase()
push_thread_beat()
def stop() -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
with _beat_run_lock:
_beat_run = None
_sim_beat_token += 1
st = _sim_beat_task
_sim_beat_task = None
if st and not st.done():
st.cancel()
def stop_if_playing_sequence(sequence_id: str) -> bool:
"""If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
sid = str(sequence_id).strip()
@@ -1007,7 +1291,11 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
cur = ctx.get("sequence_id")
if cur is None or str(cur).strip() != sid:
return False
stop()
try:
loop = asyncio.get_running_loop()
loop.create_task(stop_playback(clear_devices=True))
except RuntimeError:
stop()
return True
@@ -1016,6 +1304,29 @@ async def start(
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
) -> None:
"""Start immediately, or queue until the next beat / downbeat (``wait_for`` in *play_options*)."""
from models.sequence import Sequence
seq_m = Sequence()
sequence_doc = seq_m.read(sequence_id)
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
raise ValueError("sequence not found")
wait_for = _sequence_switch_wait_from_settings()
if wait_for:
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
_queue_pending_start(
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
)
return
await _start_immediate(zone_id, sequence_id, profile_id, play_options)
async def _start_immediate(
zone_id: str,
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
from models.preset import Preset
@@ -1052,14 +1363,11 @@ async def start(
ctx["zone_id"] = str(zone_id)
ctx["sequence_loop_beat"] = 0
await _deliver_sequence_presets_bulk(ctx)
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False})
_reset_beat_side_effects()
await _prime_all_lanes(ctx)
await _deliver_zone_brightness_for_sequence(ctx)
with _beat_run_lock:
_beat_run = ctx
await _send_all_lanes(ctx)
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
loop = asyncio.get_running_loop()

View File

@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
default=0.12,
help="Aubio detection threshold",
)
parser.add_argument(
"--silence-gate-db",
type=float,
default=-58.0,
help="Ignore beat triggers when frame RMS is below this dB level",
)
return parser.parse_args()
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
return 60.0 / float(np.median(valid))
def _is_plausible_ioi(
last_trigger_s: float,
beat_times: Deque[float],
now_s: float,
*,
min_ratio: float = 0.42,
max_ratio: float = 2.5,
) -> bool:
"""Reject double-time / half-time false triggers vs recent median interval."""
if last_trigger_s <= 0 or len(beat_times) < 2:
return True
ioi = now_s - last_trigger_s
if ioi <= 0:
return False
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
if intervals.size == 0:
return True
med = float(np.median(intervals))
if med < 0.05:
return True
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
class BarPhaseTracker:
"""Track beat-in-bar from downbeat counting (kick hints)."""
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
self.beats_per_bar = max(1, int(beats_per_bar))
self.kick_conf_min = float(kick_conf_min)
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def reset(self) -> None:
self.bar_beat = 1
self.is_downbeat = True
self.confidence = 0.0
self._last_downbeat_s = 0.0
self._aligned_kicks = 0
self._total_beats = 0
def anchor_downbeat(self, now_s: float) -> None:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self.confidence = max(self.confidence, 0.85)
def _bar_duration_s(
self, bpm: float | None, median_ioi: float | None
) -> float | None:
if bpm is not None and bpm > 0:
return (60.0 / float(bpm)) * self.beats_per_bar
if median_ioi is not None and median_ioi > 0:
return float(median_ioi) * self.beats_per_bar
return None
@staticmethod
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
if bar_dur <= 0 or elapsed <= 0:
return False
n = elapsed / bar_dur
nearest = max(1, round(n))
return abs(n - nearest) <= tol
def on_beat(
self,
now_s: float,
beat_type: str,
beat_type_conf: float,
*,
bpm: float | None = None,
median_ioi: float | None = None,
) -> dict[str, int | float | bool | str]:
self._total_beats += 1
bar_dur = self._bar_duration_s(bpm, median_ioi)
is_kick = (
str(beat_type or "").lower() == "kick"
and float(beat_type_conf or 0.0) >= self.kick_conf_min
)
downbeat_locked = False
if is_kick:
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
downbeat_locked = True
elif bar_dur and self._near_whole_bars(
now_s - self._last_downbeat_s, bar_dur
):
downbeat_locked = True
elif is_kick and self.bar_beat >= max(2, self.beats_per_bar - 1):
downbeat_locked = True
prev_bar_beat = int(self.bar_beat)
if downbeat_locked:
self.bar_beat = 1
self.is_downbeat = True
self._last_downbeat_s = float(now_s)
self._aligned_kicks += 1
elif self._total_beats <= 1:
self.bar_beat = 1
self.is_downbeat = True
else:
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
self.is_downbeat = self.bar_beat == 1
if self._total_beats >= self.beats_per_bar:
bars_seen = max(1, self._total_beats // self.beats_per_bar)
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
return {
"bar_beat": int(self.bar_beat),
"beats_per_bar": int(self.beats_per_bar),
"is_downbeat": bool(self.is_downbeat),
"phase_confidence": round(float(self.confidence), 3),
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
}
def _resolve_bpm(
beat_times: Deque[float],
aubio_bpm: float | None,
) -> float | None:
estimated = _estimate_bpm(beat_times)
if estimated is None:
return aubio_bpm
if aubio_bpm is None or aubio_bpm <= 0:
return estimated
ratio = float(aubio_bpm) / estimated
if ratio > 1.75 or ratio < 0.57:
return estimated
return estimated
def _load_aubio_if_needed(mode: str):
if mode == "custom":
return None
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
)
self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.0
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate)
@@ -192,13 +323,37 @@ class BeatDetectRuntime:
self.beat_times.clear()
self.tempo = None
if self.aubio is not None:
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
if hasattr(self.tempo, "set_threshold"):
self.tempo.set_threshold(float(self.args.aubio_threshold))
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
self._init_aubio_tempo(win_size)
def _init_aubio_tempo(self, win_size: int):
self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
)
if hasattr(self.tempo, "set_threshold"):
self.tempo.set_threshold(float(self.args.aubio_threshold))
if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
def reset_tempo_state(self) -> None:
"""Clear tempo/aubio history without losing bar phase."""
self.baseline = 1e-6
if self.prev_mag is not None:
self.prev_mag[:] = 0.0
self.beat_times.clear()
self.last_trigger_s = 0.0
if self.aubio is not None and self.sample_rate > 0:
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
self._init_aubio_tempo(win_size)
def reset_state(self):
"""Full reset (manual): tempo history and bar phase."""
self.reset_tempo_state()
self.bar_phase.reset()
def anchor_bar_phase(self, now_s: float | None = None) -> None:
if now_s is None:
now_s = time.time()
self.bar_phase.anchor_downbeat(now_s)
def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9)
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 1e-12))
if db < float(self.args.silence_gate_db):
return None
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
should_trigger = aubio_hit
else:
should_trigger = custom_hit or aubio_hit
if should_trigger and not _is_plausible_ioi(
self.last_trigger_s, self.beat_times, now_s
):
should_trigger = False
if not should_trigger:
return None
self.last_trigger_s = now_s
self.beat_times.append(now_s)
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
bpm = _resolve_bpm(self.beat_times, aubio_bpm)
strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag)
median_ioi = None
if len(self.beat_times) >= 2:
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
if intervals.size > 0:
median_ioi = float(np.median(intervals))
phase = self.bar_phase.on_beat(
now_s,
beat_type,
beat_type_conf,
bpm=bpm,
median_ioi=median_ioi,
)
if self.args.mode == "custom":
src = "custom"
elif self.args.mode == "aubio":
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
"beat_type": beat_type,
"beat_type_confidence": beat_type_conf,
"db": db,
**phase,
}

View File

@@ -0,0 +1,61 @@
"""Reset detector must not stop the stream or clear ``running``."""
import os
import sys
import time
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.audio_detector import AudioBeatDetector # noqa: E402
class _FakeRuntime:
def __init__(self):
self.reset_calls = 0
def reset_state(self):
self.reset_calls += 1
def test_reset_tracking_false_when_not_running():
det = AudioBeatDetector()
assert det.reset_tracking() is False
def test_reset_tracking_queues_on_audio_thread():
det = AudioBeatDetector()
rt = _FakeRuntime()
with det._lock:
det._running = True
det._runtime = rt
det._status["running"] = True
det._status["bpm"] = 128.0
det._status["beat_seq"] = 7
assert det.reset_tracking() is True
assert rt.reset_calls == 0
assert det._pending_reset is True
st = det.status()
assert st["running"] is True
assert st["bpm"] == 128.0
assert st["beat_seq"] == 7
det._process_pending_reset(rt)
assert rt.reset_calls == 1
assert det._pending_reset is False
assert det.status()["running"] is True
def test_status_keeps_bpm_during_holdover():
det = AudioBeatDetector()
with det._lock:
det._running = True
det._holdover_active = True
det._status["running"] = True
det._status["bpm"] = 128.0
det._status["last_beat_ts"] = time.time() - 10.0
assert det.status()["bpm"] == 128.0

70
tests/test_bar_phase.py Normal file
View File

@@ -0,0 +1,70 @@
"""Bar phase (beat-in-bar) tracking for audio beat detection."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from tests.beat_detect import BarPhaseTracker # noqa: E402
def test_bar_phase_increments_on_non_kick_beats():
tr = BarPhaseTracker(beats_per_bar=4)
r1 = tr.on_beat(1.0, "snare", 1.3, bpm=120.0)
assert r1["bar_beat"] == 1
r2 = tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
assert r2["bar_beat"] == 2
r3 = tr.on_beat(2.0, "hat", 1.1, bpm=120.0)
assert r3["bar_beat"] == 3
def test_kick_near_bar_boundary_resets_to_downbeat():
tr = BarPhaseTracker(beats_per_bar=4)
tr.on_beat(0.0, "kick", 1.4, bpm=120.0)
tr.on_beat(0.5, "snare", 1.2, bpm=120.0)
tr.on_beat(1.0, "snare", 1.2, bpm=120.0)
tr.on_beat(1.5, "snare", 1.2, bpm=120.0)
r = tr.on_beat(2.0, "kick", 1.5, bpm=120.0)
assert r["bar_beat"] == 1
assert r["is_downbeat"] is True
def test_anchor_downbeat_sets_confidence():
tr = BarPhaseTracker(beats_per_bar=4)
tr.anchor_downbeat(10.0)
assert tr.bar_beat == 1
assert tr.confidence >= 0.85
def test_reset_tempo_preserves_bar_phase():
from argparse import Namespace
from tests.beat_detect import BeatDetectRuntime # noqa: E402
args = Namespace(
mode="custom",
hop_size=256,
win_mult=2,
min_band_hz=45.0,
max_band_hz=180.0,
energy_weight=0.7,
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=100.0,
bpm_window=8,
aubio_method="default",
aubio_threshold=0.12,
beats_per_bar=4,
)
rt = BeatDetectRuntime(args)
rt.setup(44100)
rt.bar_phase.on_beat(0.0, "kick", 1.5, bpm=120.0)
rt.bar_phase.on_beat(0.5, "snare", 1.2, bpm=120.0)
assert rt.bar_phase.bar_beat == 2
rt.reset_tempo_state()
assert rt.bar_phase.bar_beat == 2
rt.reset_state()
assert rt.bar_phase.bar_beat == 1

View File

@@ -0,0 +1,28 @@
"""Beat interval plausibility helpers (audio detector)."""
import os
import sys
from collections import deque
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if PROJECT_ROOT not in sys.path:
sys.path.insert(0, PROJECT_ROOT)
from tests.beat_detect import _is_plausible_ioi, _resolve_bpm # noqa: E402
def test_is_plausible_ioi_rejects_double_time():
times = deque([0.0, 0.5, 1.0])
assert _is_plausible_ioi(1.0, times, 1.15) is False
def test_is_plausible_ioi_accepts_steady_grid():
times = deque([0.0, 0.5, 1.0])
assert _is_plausible_ioi(1.0, times, 1.5) is True
def test_resolve_bpm_prefers_intervals_over_wrong_aubio():
times = deque([0.0, 0.5, 1.0, 1.5, 2.0])
bpm = _resolve_bpm(times, 70.0)
assert bpm is not None
assert abs(bpm - 120.0) < 5.0

View File

@@ -0,0 +1,105 @@
"""Manual beat route: suppress duplicate select after sequence step change."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import beat_driver_route as bdr # noqa: E402
def _patch_delivery(monkeypatch):
delivered = []
async def fake_batch(pairs):
delivered.extend(pairs)
def fake_schedule(coro, _loop):
import asyncio
asyncio.run(coro)
monkeypatch.setattr(bdr, "_deliver_select_batch", fake_batch)
monkeypatch.setattr(bdr, "_main_loop", object())
monkeypatch.setattr("asyncio.run_coroutine_threadsafe", fake_schedule)
return delivered
def test_suppress_next_notify_skips_one_select(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"5",
{"p": "chase", "a": False, "manual_beat_n": 1},
)
bdr.mark_sequence_manual_lane_select_sent(0)
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "5")]
def test_suppress_does_not_advance_beat_counter(monkeypatch):
delivered = _patch_delivery(monkeypatch)
bdr.set_sequence_manual_lane_route(
0,
["desk"],
"42",
{"p": "radiate", "a": False, "manual_beat_n": 2},
)
bdr.mark_sequence_manual_lane_select_sent(0)
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
delivered.clear()
bdr.notify_beat_detected()
assert delivered == []
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
def test_duplicate_lanes_dedupe_to_one_select_per_beat(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
entry = {
"device_names": ["desk"],
"wire_preset_id": "42",
"pattern": "radiate",
"manual_beat_n": 1,
"beat_counter": 0,
}
with bdr._route_lock:
bdr._lane_manual.clear()
bdr._lane_manual[-1] = dict(entry)
bdr._lane_manual[0] = dict(entry)
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]
def test_standalone_overlay_skipped_when_sequence_lane_covers(monkeypatch):
delivered = _patch_delivery(monkeypatch)
body = {"p": "radiate", "a": False, "manual_beat_n": 1}
bdr.set_sequence_manual_lane_route(1, ["desk"], "42", body)
bdr._apply_manual_beat_route_standalone_overlay(["desk"], "42", body)
with bdr._route_lock:
assert -1 not in bdr._lane_manual
assert 1 in bdr._lane_manual
bdr.notify_beat_detected()
assert delivered == [(["desk"], "42")]

View File

@@ -352,19 +352,27 @@ def test_settings_controller(server):
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11})
assert resp.status_code == 200
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12})
assert resp.status_code == 400
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
resp = c.put(f"{base_url}/settings", json={"global_brightness": 42})
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
resp = c.put(
f"{base_url}/settings",
json={"sequence_switch_wait": "downbeat"},
)
assert resp.status_code == 200
resp = c.get(f"{base_url}/settings")
assert resp.json().get("sequence_switch_wait") == "downbeat"
resp = c.put(f"{base_url}/settings", json={"global_brightness": 300})
assert resp.status_code == 400

View File

@@ -0,0 +1,36 @@
"""LED strip reverse (n5) mapping for upside-down installs."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DRIVER_SRC = os.path.join(PROJECT_ROOT, "led-driver", "src")
if DRIVER_SRC not in sys.path:
sys.path.insert(0, DRIVER_SRC)
from patterns.pattern_direction import is_reversed, led_i, signed # noqa: E402
from preset import Preset # noqa: E402
class _FakeDriver:
num_leds = 10
def test_preset_reverse_sets_n5():
p = Preset({"p": "chase", "reverse": True})
assert p.n5 == 1
assert is_reversed(p) is True
def test_led_i_mirrors_index():
drv = _FakeDriver()
p = Preset({"p": "chase", "n5": 1})
assert led_i(drv, p, 0) == 9
assert led_i(drv, p, 9) == 0
assert led_i(drv, p, 3) == 6
def test_signed_negates_when_reversed():
p = Preset({"p": "chase", "n5": 1})
assert signed(p, 4) == -4
assert signed(Preset({"p": "chase", "n5": 0}), 4) == 4

View File

@@ -0,0 +1,43 @@
"""Sequence beat phase alignment (sync to musical downbeat)."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.sequence_playback import apply_beat_phase_sync # noqa: E402
def _ctx(lane_states):
return {"lane_states": lane_states, "sequence_loop_beat": 5}
def test_apply_beat_phase_sync_step_resets_beat_count_only():
ctx = _ctx(
[
{"stepIdx": 2, "beatCount": 3, "done": False},
{"stepIdx": 1, "beatCount": 1, "done": True},
]
)
ok, resend = apply_beat_phase_sync(ctx, "step")
assert ok is True
assert resend is False
assert ctx["lane_states"][0]["stepIdx"] == 2
assert ctx["lane_states"][0]["beatCount"] == 0
assert ctx["lane_states"][1]["beatCount"] == 1
assert ctx["sequence_loop_beat"] == 5
def test_apply_beat_phase_sync_pass_restarts_pass():
ctx = _ctx([{"stepIdx": 2, "beatCount": 3, "done": False}])
ok, resend = apply_beat_phase_sync(ctx, "pass")
assert ok is True
assert resend is True
st = ctx["lane_states"][0]
assert st["stepIdx"] == 0
assert st["beatCount"] == 0
assert st["done"] is False
assert ctx["sequence_loop_beat"] == 0

View File

@@ -0,0 +1,88 @@
"""Deferred sequence start on beat / downbeat."""
import asyncio
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
def test_normalize_wait_for():
assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat"
assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat"
assert sp._normalize_wait_for({"wait_for": "next_beat"}) == "beat"
assert sp._normalize_wait_for({}) is None
assert sp._play_options_without_wait({"wait_for": "beat", "zone_id": "1"}) == {"zone_id": "1"}
def test_pending_play_status_empty():
sp.clear_pending_play()
assert sp.pending_play_status() == {"pending": False}
def test_queue_and_clear_pending():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", {"simulated_bpm": 120}, "beat", bpm=120.0)
st = sp.pending_play_status()
assert st["pending"] is True
assert st["wait_for"] == "beat"
assert st["sequence_id"] == "s1"
sp.clear_pending_play()
assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_beat():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
async def fake_start(*_a, **_k):
return None
sp._start_immediate = fake_start # type: ignore[method-assign]
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_downbeat_skips_upbeat():
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False
assert sp.pending_play_status()["pending"] is True
async def fake_start(*_a, **_k):
return None
sp._start_immediate = fake_start # type: ignore[method-assign]
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=True)) is True
sp.clear_pending_play()
def test_downbeat_start_counts_trigger_beat(monkeypatch):
"""The downbeat that starts playback is beat 1 of the step, not beat 0."""
sp.clear_pending_play()
sp.stop()
async def fake_start(_z, _s, _p, _opts):
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 4}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"sequence_loop_beat": 0,
}
monkeypatch.setattr(sp, "_start_immediate", fake_start)
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
async def run():
assert await sp._try_consume_pending_play(is_downbeat=True) is True
await sp.process_active_beat_advance()
asyncio.run(run())
assert sp._beat_run["lane_states"][0]["beatCount"] == 1
sp.stop()

View File

@@ -0,0 +1,30 @@
"""Sequence playback loop flag coercion."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.sequence_playback import ( # noqa: E402
_coerce_loop,
_ordered_unique_preset_ids_in_lane,
)
def test_coerce_loop():
assert _coerce_loop({"loop": True}) is True
assert _coerce_loop({"loop": False}) is False
assert _coerce_loop({"sequence_loop": 0}) is False
assert _coerce_loop({}) is True
def test_ordered_unique_preset_ids_in_lane():
lane = [
{"preset_id": "6", "beats": 1},
{"preset_id": "4", "beats": 2},
{"preset_id": "6", "beats": 1},
]
assert _ordered_unique_preset_ids_in_lane(lane) == ["6", "4"]

View File

@@ -0,0 +1,28 @@
"""Sequence playback targets only explicitly checked lane groups."""
from util.sequence_playback import (
_group_ids_for_lane_step,
_partition_devices_for_lane,
_resolve_step_device_names,
_split_device_names_for_lane,
)
def test_empty_lane_groups_do_not_default_to_zone():
zone = {"group_ids": ["g1", "g2"]}
seq = {"lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [[]]}
gids = _group_ids_for_lane_step(seq, seq["lanes"][0][0], 0, 1, zone_doc=zone)
assert gids == []
def test_resolve_step_with_no_groups_returns_empty():
zone = {"group_ids": ["g1"], "names": ["dev-a"]}
names = _resolve_step_device_names(zone, [], None, None)
assert names == []
def test_whole_zone_not_partitioned_across_lanes():
names = ["dev-a", "dev-b", "dev-c"]
assert _split_device_names_for_lane(names, 0, 2, partition_shared_zone=False) == names
assert _split_device_names_for_lane(names, 1, 2, partition_shared_zone=False) == names
assert not _partition_devices_for_lane(2, lane_has_own_groups=False, step_group_ids=[])

22
tests/test_ui_settings.py Normal file
View File

@@ -0,0 +1,22 @@
"""Server-owned UI settings (no browser localStorage)."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.audio_run_persist import read_audio_run_state # noqa: E402
from util.sequence_playback import _sequence_switch_wait_from_settings # noqa: E402
def test_audio_run_state_includes_device_form_fields():
st = read_audio_run_state()
assert "device_override" in st
assert "device_select" in st
def test_sequence_switch_wait_from_settings():
assert _sequence_switch_wait_from_settings() in ("beat", "downbeat")

View File

@@ -0,0 +1,27 @@
"""Zone content_kind is fixed after create."""
import json
import os
import sys
import tempfile
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.zone import Zone # noqa: E402
def test_update_cannot_change_content_kind():
with tempfile.TemporaryDirectory() as tmp:
path = os.path.join(tmp, "zone.json")
with open(path, "w", encoding="utf-8") as f:
json.dump({}, f)
z = Zone()
z.file = path
z.clear()
zid = z.create("preset zone", group_ids=[], content_kind="presets")
z.update(zid, {"content_kind": "sequences", "name": "preset zone"})
doc = z.read(zid)
assert doc["content_kind"] == "presets"
assert doc.get("sequence_ids") == []