17 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
879db2a7df chore(submodules): bump led-driver and led-tool
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:14:57 +12:00
96d1e1b5fd feat(ui): pattern modes, bundles, and zone content kind
Add profile/preset/sequence JSON import and export; map preset mode to
wire n6 with a mode dropdown for multi-mode patterns; zone edit shows
presets or sequences only with content_kind on save; update catalogue
and tests for merged pattern names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 21:12:42 +12:00
6286297646 feat(patterns): register northern wave, candle glow, starfall, ice sparkle
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:11:33 +12:00
ca3fef3f8a feat(patterns): winter icicles blizzard rime in controller catalogue
Register pattern metadata and test presets for new led-driver effects; bump led-driver submodule.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 15:10:02 +12:00
59 changed files with 4942 additions and 1338 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"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"]} {"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

@@ -11,15 +11,13 @@
"max_colors": 0, "max_colors": 0,
"supports_manual": true "supports_manual": true
}, },
"rainbow": {
"n1": "Step Rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 0,
"supports_manual": true
},
"colour_cycle": { "colour_cycle": {
"n1": "Step Rate", "supports_reverse": true,
"n1": "Step rate",
"mode": {
"0": "Scroll palette gradient",
"1": "Rainbow wheel (preset colours ignored)"
},
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"max_colors": 10, "max_colors": 10,
@@ -32,6 +30,7 @@
"supports_manual": false "supports_manual": false
}, },
"chase": { "chase": {
"supports_reverse": true,
"n1": "Colour 1 Length", "n1": "Colour 1 Length",
"n2": "Colour 2 Length", "n2": "Colour 2 Length",
"n3": "Step 1", "n3": "Step 1",
@@ -40,7 +39,11 @@
"max_delay": 10000, "max_delay": 10000,
"max_colors": 2, "max_colors": 2,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true,
"mode": {
"0": "Two-colour chase",
"1": "Marquee dashes (n1 on length, n2 off, n3 step)"
}
}, },
"pulse": { "pulse": {
"n1": "Attack", "n1": "Attack",
@@ -80,7 +83,7 @@
"flame": { "flame": {
"n1": "Min brightness", "n1": "Min brightness",
"n2": "Breath period (ms)", "n2": "Breath period (ms)",
"n3": "Spark gap min (ms, 0=default 1030 s, -1=off)", "n3": "Spark gap min (ms, 0=default 10\u201330 s, -1=off)",
"n4": "Spark gap max (ms)", "n4": "Spark gap max (ms)",
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
@@ -88,8 +91,8 @@
"supports_manual": false "supports_manual": false
}, },
"twinkle": { "twinkle": {
"n1": "Twinkle activity (1255, higher = more changes)", "n1": "Twinkle activity (1\u2013255, higher = more changes)",
"n2": "Density (0255, higher = more of the strip lit)", "n2": "Density (0\u2013255, higher = more of the strip lit)",
"n3": "Min adjacent LEDs per twinkle (same as max for fixed length)", "n3": "Min adjacent LEDs per twinkle (same as max for fixed length)",
"n4": "Max adjacent LEDs per twinkle (same as min for fixed length)", "n4": "Max adjacent LEDs per twinkle (same as min for fixed length)",
"min_delay": 10, "min_delay": 10,
@@ -108,58 +111,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"meteor_rain": {
"n1": "Tail length",
"n2": "Speed (LEDs per frame)",
"n3": "Fade amount (1-255)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"scanner": {
"n1": "Eye width",
"n2": "End pause (frames)",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"gradient_scroll": {
"n1": "Scroll step rate",
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"supports_manual": true
},
"comet_dual": {
"n1": "Tail length",
"n2": "Speed",
"n3": "Gap",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"sparkle_trail": {
"n1": "Spark density",
"n2": "Decay",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": true
},
"wave": {
"n1": "Wavelength",
"n2": "Amplitude",
"n3": "Drift speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"plasma": { "plasma": {
"n1": "Scale", "n1": "Scale",
"n2": "Speed", "n2": "Speed",
@@ -169,17 +120,6 @@
"max_delay": 10000, "max_delay": 10000,
"supports_manual": false "supports_manual": false
}, },
"segment_chase": {
"n1": "Segment size",
"n2": "Phase step",
"n3": "Segment phase offset",
"n4": "Gap per segment",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"bar_graph": { "bar_graph": {
"n1": "Level percent", "n1": "Level percent",
"max_colors": 10, "max_colors": 10,
@@ -188,14 +128,6 @@
"has_background": true, "has_background": true,
"supports_manual": false "supports_manual": false
}, },
"breathing_dual": {
"n1": "Phase offset",
"n2": "Ease",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"strobe_burst": { "strobe_burst": {
"n1": "Burst count", "n1": "Burst count",
"n2": "Burst gap", "n2": "Burst gap",
@@ -215,15 +147,6 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"fireflies": {
"n1": "Count",
"n2": "Twinkle speed",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"clock_sweep": { "clock_sweep": {
"n1": "Hand width", "n1": "Hand width",
"n2": "Marker interval", "n2": "Marker interval",
@@ -233,37 +156,57 @@
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"marquee": { "aurora": {
"n1": "On length", "supports_reverse": true,
"n2": "Off length", "n1": "Band count (0) or spatial period LEDs (1)",
"n3": "Step", "n2": "Shimmer (0) or blend strength (1)",
"n3": "Unused (0) or drift speed (1)",
"mode": {
"0": "Colour bands + shimmer",
"1": "Sine northern wave"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"icicles": {
"supports_reverse": true,
"n1": "Anchor spacing (LEDs)",
"n2": "Max icicle length (LEDs)",
"n3": "Phase step per refresh",
"max_colors": 10, "max_colors": 10,
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"aurora": { "blizzard": {
"n1": "Band count", "supports_reverse": true,
"n2": "Shimmer",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"supports_manual": false
},
"snowfall": {
"n1": "Flake density", "n1": "Flake density",
"n2": "Fall speed", "n2": "Fall speed",
"n3": "Wind (128 = centred; lower/raise for drift bias)",
"max_colors": 10, "max_colors": 10,
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"has_background": true, "has_background": true,
"supports_manual": true "supports_manual": true
}, },
"heartbeat": { "rime": {
"n1": "Pulse 1 ms", "n1": "Crystallisation rate",
"n2": "Pulse 2 ms", "n2": "Melt (decay) per refresh",
"n3": "Pause ms", "n3": "Spark cap (LEDs refreshed per cycle)",
"max_colors": 10,
"min_delay": 10,
"max_delay": 10000,
"has_background": true,
"supports_manual": true
},
"candle_glow": {
"n1": "Candle count",
"n2": "Glow width (LEDs)",
"n3": "Flicker strength",
"max_colors": 10, "max_colors": 10,
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
@@ -287,5 +230,51 @@
"min_delay": 10, "min_delay": 10,
"max_delay": 10000, "max_delay": 10000,
"supports_manual": false "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)",
"mode": {
"0": "Fading meteor",
"1": "Dual comets",
"2": "Bouncing scanner"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"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)",
"mode": {
"0": "Snowfall flakes",
"1": "Starfall streaks"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
},
"sparkle": {
"n1": "Spark density (01) or firefly count (2)",
"n2": "Trail decay (0) or twinkle speed (2)",
"n3": "Ice halo width LEDs (1); unused in 0 and 2",
"mode": {
"0": "Sparkle trail",
"1": "Ice burst + halo",
"2": "Fireflies"
},
"min_delay": 10,
"max_delay": 10000,
"max_colors": 10,
"has_background": true,
"supports_manual": true
} }
} }

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1": {"name": "default", "type": "zones", "zones": ["1", "8"], "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

@@ -1,3 +1,5 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_endpoints_pytest.py"] python_files = ["test_*.py"]
# ``tests/models/`` is a package name clash with ``src/models``; run via tests/models/run_all.py
norecursedirs = ["models"]

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

View File

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

View File

@@ -3,20 +3,40 @@ import os
import subprocess import subprocess
import sys import sys
from microdot import Microdot from microdot import Microdot, send_file
from serial.tools import list_ports from serial.tools import list_ports
controller = Microdot() controller = Microdot()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
)
def _repo_root() -> str: def _repo_root() -> str:
return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) 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: def _led_cli_path() -> str:
return os.path.join(_repo_root(), "led-tool", "cli.py") 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): def _build_led_cli_command(port: str, payload: dict):
cmd = [sys.executable, _led_cli_path(), "--port", port] cmd = [sys.executable, _led_cli_path(), "--port", port]
@@ -92,16 +112,40 @@ def _extract_settings_from_stdout(stdout: str):
return None 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") @controller.get("/ports")
async def list_serial_ports(request): async def list_serial_ports(request):
ports = [] ports = _filter_host_serial_ports(
for info in list_ports.comports(): [
ports.append(
{ {
"device": info.device, "device": info.device,
"description": info.description, "description": info.description,
"hwid": info.hwid, "hwid": info.hwid,
} }
for info in list_ports.comports()
]
) )
return ( return (
json.dumps( json.dumps(

View File

@@ -7,6 +7,7 @@ from models.device import Device, normalize_mac
from models.transport import get_current_sender from models.transport import get_current_sender
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
from util.espnow_message import build_message, build_preset_dict from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json import json
controller = Microdot() controller = Microdot()
@@ -50,6 +51,41 @@ async def list_presets(request, session):
} }
return json.dumps(scoped), 200, {'Content-Type': 'application/json'} return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>/export')
@with_session
async def export_preset(request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
try:
bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
@controller.post('/import')
@with_session
async def import_preset(request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<preset_id>') @controller.get('/<preset_id>')
@with_session @with_session
async def get_preset(request, session, preset_id): async def get_preset(request, session, preset_id):

View File

@@ -3,12 +3,15 @@ from microdot.session import with_session
from models.profile import Profile from models.profile import Profile
from models.zone import Zone from models.zone import Zone
from models.preset import Preset from models.preset import Preset
from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json import json
controller = Microdot() controller = Microdot()
profiles = Profile() profiles = Profile()
zones = Zone() zones = Zone()
presets = Preset() presets = Preset()
sequences = Sequence()
@controller.get('') @controller.get('')
@with_session @with_session
@@ -54,18 +57,64 @@ async def get_current_profile(request, session):
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'} return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404 return json.dumps({"error": "No profile available"}), 404
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id) @controller.post('/import')
if profile: @with_session
return json.dumps(profile), 200, {'Content-Type': 'application/json'} async def import_profile(request, session):
return json.dumps({"error": "Profile not found"}), 404 """Import a profile bundle (optionally apply as current profile)."""
try:
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
name = body.get("name") if isinstance(body, dict) else None
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
if isinstance(apply_raw, str):
apply = apply_raw.strip().lower() in ("1", "true", "yes", "on")
else:
apply = bool(apply_raw)
new_profile_id, profile_data = import_profile_bundle(
bundle,
profiles,
zones,
presets,
sequences,
profiles._palette_model,
name=str(name).strip() if name else None,
)
if apply:
session['current_profile'] = str(new_profile_id)
session.save()
return (
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
201,
{'Content-Type': 'application/json'},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.get('/<id>/export')
async def export_profile(request, id):
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
try:
bundle = export_profile_bundle(
str(id),
profiles,
zones,
presets,
sequences,
profiles._palette_model,
)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
@controller.post('/<id>/apply') @controller.post('/<id>/apply')
@with_session @with_session
@@ -77,167 +126,6 @@ async def apply_profile(request, session, id):
session.save() session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'} return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "rainbow",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.post('/<id>/clone') @controller.post('/<id>/clone')
async def clone_profile(request, id): async def clone_profile(request, id):
@@ -351,6 +239,184 @@ async def clone_profile(request, id):
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400
@controller.get('/<id>')
@with_session
async def get_profile(request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
return await get_current_profile(request, session)
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('')
async def create_profile(request):
"""Create a new profile."""
try:
data = dict(request.json or {})
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
seed_dj_zone = seed_raw.strip().lower() in ("1", "true", "yes", "on")
else:
seed_dj_zone = bool(seed_raw)
# Request-only flag: do not persist on profile records.
data.pop("seed_dj_zone", None)
profile_id = profiles.create(name)
# Avoid persisting request-only fields.
data.pop("name", None)
if data:
profiles.update(profile_id, data)
# New profiles always start with a default zone pre-populated with starter presets.
default_preset_ids = []
default_preset_defs = [
{
"name": "on",
"pattern": "on",
"colors": ["#FFFFFF"],
"brightness": 255,
"delay": 100,
"auto": True,
},
{
"name": "off",
"pattern": "off",
"colors": [],
"brightness": 0,
"delay": 100,
"auto": True,
},
{
"name": "rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 2,
"mode": 1,
},
{
"name": "Colour Cycle",
"pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 100,
"auto": True,
"n1": 1,
},
{
"name": "transition",
"pattern": "transition",
"colors": ["#FF0000", "#00FF00", "#0000FF"],
"brightness": 255,
"delay": 500,
"auto": True,
},
{
"name": "flicker",
"pattern": "flicker",
"colors": ["#FFB84D"],
"brightness": 255,
"delay": 80,
"auto": True,
"n1": 30,
},
{
"name": "flame",
"pattern": "flame",
"colors": [],
"brightness": 255,
"delay": 50,
"auto": True,
"n1": 35,
"n2": 2600,
"n3": 0,
"n4": 0,
},
{
"name": "twinkle",
"pattern": "twinkle",
"colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"],
"brightness": 255,
"delay": 55,
"auto": True,
"n1": 72,
"n2": 140,
"n3": 2,
"n4": 6,
},
]
for preset_data in default_preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
default_preset_ids.append(str(pid))
default_tab_id = zones.create(name="default", names=["1"], presets=[default_preset_ids])
zones.update(default_tab_id, {
"presets_flat": default_preset_ids,
"default_preset": default_preset_ids[0] if default_preset_ids else None,
})
profile = profiles.read(profile_id) or {}
profile_tabs = profile.get("zones", []) if isinstance(profile.get("zones", []), list) else []
profile_tabs.append(str(default_tab_id))
if seed_dj_zone:
# Seed a DJ-focused zone with three starter presets.
seeded_preset_ids = []
preset_defs = [
{
"name": "DJ Rainbow",
"pattern": "colour_cycle",
"colors": [],
"brightness": 220,
"delay": 60,
"n1": 12,
"mode": 1,
},
{
"name": "DJ Single Color",
"pattern": "on",
"colors": ["#ff00ff"],
"brightness": 220,
"delay": 100,
},
{
"name": "DJ Transition",
"pattern": "transition",
"colors": ["#ff0000", "#00ff00", "#0000ff"],
"brightness": 220,
"delay": 250,
},
]
for preset_data in preset_defs:
pid = presets.create(profile_id)
presets.update(pid, preset_data)
seeded_preset_ids.append(str(pid))
dj_tab_id = zones.create(name="dj", names=["dj"], presets=[seeded_preset_ids])
zones.update(dj_tab_id, {
"presets_flat": seeded_preset_ids,
"default_preset": seeded_preset_ids[0] if seeded_preset_ids else None,
})
profile_tabs.append(str(dj_tab_id))
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current') @controller.put('/current')
@with_session @with_session
async def update_current_profile(request, session): async def update_current_profile(request, session):

View File

@@ -3,11 +3,14 @@ from microdot.session import with_session
from models.sequence import Sequence from models.sequence import Sequence
from models.profile import Profile from models.profile import Profile
from models.transport import get_current_sender from models.transport import get_current_sender
from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json import json
controller = Microdot() controller = Microdot()
sequences = Sequence() sequences = Sequence()
profiles = Profile() profiles = Profile()
presets = Preset()
def get_current_profile_id(session=None): def get_current_profile_id(session=None):
@@ -27,6 +30,7 @@ def get_current_profile_id(session=None):
@with_session @with_session
async def list_sequences(request, session): async def list_sequences(request, session):
"""List sequences for the current profile.""" """List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not current_profile_id: if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"} return json.dumps({}), 200, {"Content-Type": "application/json"}
@@ -39,10 +43,62 @@ async def list_sequences(request, session):
return json.dumps(scoped), 200, {"Content-Type": "application/json"} return json.dumps(scoped), 200, {"Content-Type": "application/json"}
@controller.get("/<id>/export")
@with_session
async def export_sequence(request, session, id):
"""Export a sequence and referenced presets as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
try:
bundle = export_sequence_bundle(
id,
sequences,
presets,
profile_id=current_profile_id,
)
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
@controller.post("/import")
@with_session
async def import_sequence(request, session):
"""Import a sequence bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
body = request.json or {}
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return (
json.dumps({"error": "Expected JSON bundle"}),
400,
{"Content-Type": "application/json"},
)
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
return (
json.dumps({new_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.get("/<id>") @controller.get("/<id>")
@with_session @with_session
async def get_sequence(request, session, id): async def get_sequence(request, session, id):
"""Get a specific sequence by ID (current profile only).""" """Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
seq = sequences.read(id) seq = sequences.read(id)
if ( if (
@@ -149,15 +205,46 @@ async def delete_sequence(request, session, id):
return json.dumps({"error": "Sequence not found"}), 404 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") @controller.post("/stop")
@with_session @with_session
async def stop_sequence_playback(request, session): async def stop_sequence_playback(request, session):
"""Stop server-driven zone sequence playback.""" """Stop server-driven zone sequence playback."""
_ = request _ = request
try: 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"} return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"} return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
@@ -197,8 +284,12 @@ async def play_sequence(request, session, id):
try: try:
from util.sequence_playback import start from util.sequence_playback import start
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None) play_opts = data if isinstance(data, dict) else None
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"} 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: except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
except RuntimeError as e: except RuntimeError as e:

View File

@@ -4,10 +4,10 @@ import json
from microdot import Microdot, send_file from microdot import Microdot, send_file
from models import wifi_ws_clients from models import wifi_ws_clients
from settings import Settings from settings import get_settings
controller = Microdot() controller = Microdot()
settings = Settings() settings = get_settings()
@controller.get('') @controller.get('')
async def get_settings(request): async def get_settings(request):
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
return v 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): async def update_settings(request):
"""Update general settings.""" """Update general settings."""
try: try:
@@ -87,6 +101,10 @@ async def update_settings(request):
elif key == 'global_brightness' and value is not None: elif key == 'global_brightness' and value is not None:
settings[key] = _validate_global_brightness(value) settings[key] = _validate_global_brightness(value)
global_brightness_changed = True 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: else:
settings[key] = value settings[key] = value
settings.save() settings.save()

View File

@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
@controller.get("") @controller.get("")
@with_session @with_session
async def list_zones(request, session): async def list_zones(request, session):
zones.load()
profile_id = get_current_profile_id(session) profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session) current_zone_id = get_current_zone_id(request, session)
zone_order = get_profile_zone_order(profile_id) if profile_id else [] 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>") @controller.get("/<id>")
async def get_zone(request, id): async def get_zone(request, id):
zones.load()
z = zones.read(id) z = zones.read(id)
if z: if z:
return json.dumps(z), 200, {"Content-Type": "application/json"} return json.dumps(z), 200, {"Content-Type": "application/json"}
@@ -349,6 +351,7 @@ async def clone_zone(request, session, id):
source.get("names"), source.get("names"),
source.get("presets"), source.get("presets"),
source.get("group_ids"), source.get("group_ids"),
source.get("content_kind"),
) )
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")} extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
if extra: if extra:

View File

@@ -10,7 +10,7 @@ import traceback
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session from microdot.session import Session
from settings import Settings from settings import get_settings
import controllers.preset as preset import controllers.preset as preset
import controllers.profile as profile 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: def _prime_wifi_outbound_driver_connections() -> None:
""" """On boot, dial each registered Wi-Fi driver (same 4-attempt limit as UDP hello)."""
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.
"""
n = 0 n = 0
try: try:
dev = Device() dev = Device()
@@ -143,65 +139,6 @@ def _ipv4_address(addr: str) -> str | None:
return s 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: async def _run_udp_discovery_server(udp_holder=None) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False) sock.setblocking(False)
@@ -244,7 +181,7 @@ async def _send_bridge_wifi_channel(settings, sender):
async def main(port=80): async def main(port=80):
settings = Settings() settings = get_settings()
print(settings) print(settings)
print("Starting") print("Starting")
@@ -377,7 +314,12 @@ async def main(port=80):
audio_detector.start(device=device) audio_detector.start(device=device)
from util.audio_run_persist import write_audio_run_state 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()} return {"ok": True, "status": audio_detector.status()}
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)}, 500 return {"ok": False, "error": str(e)}, 500
@@ -391,6 +333,24 @@ async def main(port=80):
write_audio_run_state(enabled=False) write_audio_run_state(enabled=False)
return {"ok": True, "status": audio_detector.status()} 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') @app.route('/api/audio/status')
async def audio_status(request): async def audio_status(request):
_ = request _ = request
@@ -426,6 +386,14 @@ async def main(port=80):
if bs > 0: if bs > 0:
beat_readout = str(bs) beat_readout = str(bs)
st["beat_readout"] = beat_readout 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} return {"status": st}
# Static file route # Static file route
@@ -480,16 +448,30 @@ async def main(port=80):
await _send_bridge_wifi_channel(settings, sender) await _send_bridge_wifi_channel(settings, sender)
_prime_wifi_outbound_driver_connections() _prime_wifi_outbound_driver_connections()
udp_holder = {"closing": False} udp_holder = {"closing": False, "shutting_down": False}
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
server_tasks: list[asyncio.Task] = []
def _graceful_shutdown(*_args): def _graceful_shutdown(*_args):
if udp_holder.get("shutting_down"):
raise SystemExit(0)
udp_holder["shutting_down"] = True
print("[server] shutting down...") print("[server] shutting down...")
udp_holder["closing"] = True udp_holder["closing"] = True
try: try:
audio_detector.stop() audio_detector.stop()
except Exception: except Exception:
pass 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") u = udp_holder.get("sock")
if u is not None: if u is not None:
try: try:
@@ -498,7 +480,13 @@ async def main(port=80):
pass pass
tcp_client_registry.cancel_all_driver_tasks() tcp_client_registry.cancel_all_driver_tasks()
if getattr(app, "server", None) is not None: if getattr(app, "server", None) is not None:
try:
app.shutdown() app.shutdown()
except Exception:
pass
for t in server_tasks:
if not t.done():
t.cancel()
shutdown_handlers_registered = False shutdown_handlers_registered = False
try: try:
@@ -511,11 +499,17 @@ async def main(port=80):
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here. # Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
try: try:
await asyncio.gather( server_tasks[:] = [
app.start_server(host="0.0.0.0", port=port), asyncio.create_task(
_run_udp_discovery_server(udp_holder), app.start_server(host="0.0.0.0", port=port), name="http"
_periodic_wifi_driver_hello_loop(settings, udp_holder), ),
) asyncio.create_task(
_run_udp_discovery_server(udp_holder), name="udp"
),
]
await asyncio.gather(*server_tasks)
except asyncio.CancelledError:
pass
except OSError as e: except OSError as e:
if e.errno == errno.EADDRINUSE: if e.errno == errno.EADDRINUSE:
print( print(
@@ -540,6 +534,21 @@ async def main(port=80):
app.server = None app.server = None
except Exception: except Exception:
pass 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: if shutdown_handlers_registered:
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
try: try:
@@ -549,5 +558,9 @@ async def main(port=80):
if __name__ == "__main__": if __name__ == "__main__":
import os import os
port = int(os.environ.get("PORT", 80)) port = int(os.environ.get("PORT", 80))
try:
asyncio.run(main(port=port)) 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] = {} _connections: dict[str, object] = {}
_send_locks: dict[str, asyncio.Lock] = {} _send_locks: dict[str, asyncio.Lock] = {}
_tasks: dict[str, asyncio.Task] = {} _tasks: dict[str, asyncio.Task] = {}
_unreachable_counts: dict[str, int] = {}
_settings = None _settings = None
_tcp_status_broadcast = None _tcp_status_broadcast = None
@@ -119,7 +118,6 @@ def _register_ws(ip: str, ws) -> None:
if not key: if not key:
return return
_connections[key] = ws _connections[key] = ws
_unreachable_counts.pop(key, None)
if key not in _send_locks: if key not in _send_locks:
_send_locks[key] = asyncio.Lock() _send_locks[key] = asyncio.Lock()
_schedule_status_broadcast(key, True) _schedule_status_broadcast(key, True)
@@ -275,52 +273,43 @@ async def _driver_connection_loop(ip: str) -> None:
if stagger > 0: if stagger > 0:
await asyncio.sleep(stagger) 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: try:
while True: for attempt in range(1, max_boot_attempts + 1):
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
try: try:
print(f"[WS] connecting to {uri!r}") print(f"[WS] connecting to {uri!r} (attempt {attempt}/{max_boot_attempts})")
async with websockets.connect( async with websockets.connect(
uri, uri,
ping_interval=20, ping_interval=20,
ping_timeout=15, ping_timeout=15,
open_timeout=open_timeout, open_timeout=open_timeout,
) as ws: ) as ws:
connected_once = True
_register_ws(ip, ws) _register_ws(ip, ws)
try: try:
await _recv_forward_loop(ip, ws) await _recv_forward_loop(ip, ws)
finally: finally:
unregister_tcp_writer(ip, ws) unregister_tcp_writer(ip, ws)
return
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except ConnectionClosed as e: except ConnectionClosed as e:
print(f"[WS] driver {ip} closed: {e}") print(f"[WS] driver {ip} closed: {e}")
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
return
except Exception as e: except Exception as e:
if _benign_ws_connect_failure(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( print(
f"[WS] driver {ip} unreachable, retry in {retry_interval_s}s: {e} (x{n})" f"[WS] driver {ip} unreachable (attempt {attempt}/{max_boot_attempts}): {e}"
) )
else: else:
print(f"[WS] driver {ip} session error: {e!r}") print(f"[WS] driver {ip} session error: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__) traceback.print_exception(type(e), e, e.__traceback__)
_unreachable_counts.pop(ip, None)
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
if attempt < max_boot_attempts:
await asyncio.sleep(retry_interval_s) 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: except asyncio.CancelledError:
unregister_tcp_writer(ip, None) unregister_tcp_writer(ip, None)
raise raise
@@ -329,10 +318,12 @@ async def _driver_connection_loop(ip: str) -> None:
def ensure_driver_connection(peer_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) key = normalize_tcp_peer_ip(peer_ip)
if not key: if not key:
return return
if tcp_client_connected(key):
return
t = _tasks.get(key) t = _tasks.get(key)
if t is not None and not t.done(): if t is not None and not t.done():
return return
@@ -353,4 +344,3 @@ def cancel_all_driver_tasks() -> None:
_schedule_status_broadcast(ip, False) _schedule_status_broadcast(ip, False)
_connections.clear() _connections.clear()
_send_locks.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. """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\"`` 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): def __init__(self):
@@ -43,9 +43,66 @@ class Zone(Model):
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict): if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
doc["preset_group_ids"] = {} doc["preset_group_ids"] = {}
changed = True 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: if changed:
self.save() self.save()
@staticmethod
def _normalized_content_kind(doc):
if not isinstance(doc, dict):
return None
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)
if kind == "presets":
doc["sequence_ids"] = []
elif kind == "sequences":
doc["presets"] = []
doc["presets_flat"] = []
def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None): def create(self, name="", names=None, presets=None, group_ids=None, content_kind=None):
next_id = self.get_next_id() next_id = self.get_next_id()
gid_list = [] gid_list = []
@@ -62,6 +119,9 @@ class Zone(Model):
} }
if content_kind in ("presets", "sequences"): if content_kind in ("presets", "sequences"):
doc["content_kind"] = content_kind doc["content_kind"] = content_kind
if "sequence_ids" not in doc:
doc["sequence_ids"] = []
self._enforce_content_kind_invariants(doc)
self[next_id] = doc self[next_id] = doc
self.save() self.save()
return next_id return next_id
@@ -74,7 +134,14 @@ class Zone(Model):
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
self[id_str].update(data) 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)
if "content_kind" in patch:
self._enforce_content_kind_invariants(self[id_str])
self.save() self.save()
return True return True

View File

@@ -12,11 +12,15 @@ def _settings_path():
return "settings.json" return "settings.json"
_settings_singleton: "Settings | None" = None
class Settings(dict): class Settings(dict):
SETTINGS_FILE = None # Set in __init__ from _settings_path() SETTINGS_FILE = None # Set in __init__ from _settings_path()
def __init__(self): def __init__(self, *, quiet: bool = False):
super().__init__() super().__init__()
self._quiet = quiet
if Settings.SETTINGS_FILE is None: if Settings.SETTINGS_FILE is None:
Settings.SETTINGS_FILE = _settings_path() Settings.SETTINGS_FILE = _settings_path()
self.load() # Load settings from file during initialization self.load() # Load settings from file during initialization
@@ -53,12 +57,9 @@ class Settings(dict):
self['wifi_driver_ws_port'] = 80 self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self: if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws' self['wifi_driver_ws_path'] = '/ws'
# Seconds between UDP discovery nudges when a Wi-Fi driver WebSocket is # Legacy (unused): periodic UDP nudges removed; connect only on driver hello.
# down (0 disables). Helps drivers that reconnect after seeing traffic on 8766.
if 'wifi_driver_hello_interval_s' not in self: if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0 self['wifi_driver_hello_interval_s'] = 0
# Legacy key (no longer read): initial outbound dial limit uses
# wifi_driver_initial_connect_attempts instead.
if 'wifi_driver_connect_retry_window_s' not in self: if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0 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. # 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). # Pause between outbound WebSocket dial attempts (seconds).
if 'wifi_driver_connect_retry_interval_s' not in self: if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0 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: if 'wifi_driver_initial_connect_attempts' not in self:
self['wifi_driver_initial_connect_attempts'] = 4 self['wifi_driver_initial_connect_attempts'] = 4
# UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial). # UART to ESP32 ESP-NOW bridge; default off (Wi-Fi drivers need no serial).
@@ -79,12 +80,21 @@ class Settings(dict):
# Zone UI global brightness (0255); shared across browsers/devices. # Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self: if 'global_brightness' not in self:
self['global_brightness'] = 255 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): def save(self):
try: try:
j = json.dumps(self) j = json.dumps(self)
with open(self.SETTINGS_FILE, 'w') as file: with open(self.SETTINGS_FILE, 'w') as file:
file.write(j) file.write(j)
if not getattr(self, "_quiet", False):
print("Settings saved successfully.") print("Settings saved successfully.")
except Exception as e: except Exception as e:
print(f"Error saving settings: {e}") print(f"Error saving settings: {e}")
@@ -96,9 +106,11 @@ class Settings(dict):
loaded_settings = json.load(file) loaded_settings = json.load(file)
self.update(loaded_settings) self.update(loaded_settings)
loaded_from_file = True loaded_from_file = True
if not getattr(self, "_quiet", False):
print("Settings loaded successfully.") print("Settings loaded successfully.")
except Exception as e: except Exception as e:
print(f"Error loading settings") if not getattr(self, "_quiet", False):
print(f"Error loading settings: {e}")
self.clear() self.clear()
finally: finally:
# Ensure defaults are set even if file exists but is missing keys # 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 # Only save if file didn't exist or was invalid
if not loaded_from_file: if not loaded_from_file:
self.save() 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 pollTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0; let lastBeatSeq = 0;
let lastLoggedSequenceBeatFractions = ""; let lastLoggedSequenceBeatFractions = "";
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
@@ -14,49 +15,6 @@
/** @type {Set<ReturnType<typeof setTimeout>>} */ /** @type {Set<ReturnType<typeof setTimeout>>} */
const pendingBeatPhaseTimers = new Set(); 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) { function el(id) {
return document.getElementById(id); return document.getElementById(id);
} }
@@ -155,21 +113,115 @@
node.textContent = `${label}${conf}`; 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) { function setTopBpmVisible(on) {
const top = el("audio-top-indicator"); const top = el("audio-top-indicator");
if (!top) return; if (!top) return;
top.classList.toggle("audio-running", !!on); 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() { function flashBeat() {
const node = el("audio-beat-flash"); const node = el("audio-beat-flash");
if (!node) return; if (!node) return;
node.classList.add("active"); node.classList.add("active");
setTimeout(() => node.classList.remove("active"), 80); setTimeout(() => node.classList.remove("active"), 80);
const syncBtn = el("audio-top-beat-sync");
const top = el("audio-top-indicator"); const top = el("audio-top-indicator");
if (top && top.classList.contains("audio-running")) { if (syncBtn && top && top.classList.contains("audio-running")) {
top.classList.add("flash"); syncBtn.classList.add("flash");
setTimeout(() => top.classList.remove("flash"), 90); setTimeout(() => syncBtn.classList.remove("flash"), 90);
} }
} }
@@ -184,17 +236,17 @@
const n = parseInt(String(inp.value).trim(), 10); const n = parseInt(String(inp.value).trim(), 10);
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n)); 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 { 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) { } catch (e) {
console.warn("beat phase ms save failed", 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). */ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() { async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false); setTopBpmVisible(false);
setNavResetVisible(false);
clearBeatPhaseTimers(); clearBeatPhaseTimers();
if (pollTimer) { if (pollTimer) {
clearInterval(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() { async function stopAudio() {
await stopAudioOnly(); await stopAudioOnly();
clearRestorePrefs();
} }
async function pollStatus() { async function pollStatus() {
@@ -258,24 +311,34 @@
node.textContent = String(status.error).trim().slice(0, 120); node.textContent = String(status.error).trim().slice(0, 120);
} }
updateBeatReadoutDisplays({}); updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null); updateBpmDisplay(null);
setTopBpmVisible(!!status.running); setTopBpmVisible(!!status.running);
setNavResetVisible(!!status.running);
if (!status.running && pollTimer) { if (!status.running && pollTimer) {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = null; pollTimer = null;
} }
return; return;
} }
setTopBpmVisible(!!status.running); audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
setNavResetVisible(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm); updateBpmDisplay(status.bpm);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence)); 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 * `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` / * after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
* `sequence` on each poll. * `sequence` on each poll.
*/ */
const beatSeq = Number(status.beat_seq || 0); const beatSeq = Number(status.beat_seq || 0);
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive; const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive; const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive; prevZoneSequencePlaybackActive = zoneSeqActive;
@@ -320,7 +383,11 @@
const selected = el("audio-device-select")?.value || ""; const selected = el("audio-device-select")?.value || "";
const rawDevice = override !== "" ? override : selected; const rawDevice = override !== "" ? override : selected;
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice; 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", { const res = await fetch("/api/audio/start", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -330,7 +397,6 @@
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Failed to start audio detector"); throw new Error(data.error || "Failed to start audio detector");
} }
writeRestorePrefs(override, selected);
updateBpmDisplay(null); updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN); updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
@@ -378,6 +444,7 @@
const closeBtn = el("audio-close-btn"); const closeBtn = el("audio-close-btn");
const startBtn = el("audio-start-btn"); const startBtn = el("audio-start-btn");
const stopBtn = el("audio-stop-btn"); const stopBtn = el("audio-stop-btn");
const navResetBtn = el("audio-nav-reset-btn");
const refreshBtn = el("audio-refresh-btn"); const refreshBtn = el("audio-refresh-btn");
if (!modal || !openBtn) return; if (!modal || !openBtn) return;
@@ -410,6 +477,9 @@
await stopAudio(); await stopAudio();
}); });
} }
if (navResetBtn) {
navResetBtn.addEventListener("click", () => resetAudioTracking());
}
if (refreshBtn) { if (refreshBtn) {
refreshBtn.addEventListener("click", async () => { refreshBtn.addEventListener("click", async () => {
try { try {
@@ -422,17 +492,41 @@
const phaseInp = el("audio-beat-phase-ms"); const phaseInp = el("audio-beat-phase-ms");
if (phaseInp) { if (phaseInp) {
phaseInp.addEventListener("change", () => {
void persistBeatPhaseMs();
});
phaseInp.addEventListener("input", () => {
void persistBeatPhaseMs();
});
}
const bindSync = (node, mode) => {
if (!node) return;
node.addEventListener("click", async () => {
try { try {
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10); await syncSequenceBeatPhase(mode);
if (Number.isFinite(stored)) { } catch (e) {
phaseInp.value = String(Math.min(500, Math.max(0, stored))); console.warn("sequence beat sync failed", e);
} }
} catch { });
/* ignore */ };
} const topBpm = el("audio-top-beat-sync");
phaseInp.addEventListener("change", () => persistBeatPhaseMs()); if (topBpm) {
phaseInp.addEventListener("input", () => persistBeatPhaseMs()); 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() { async function resumePollingIfDetectorRunning() {
@@ -440,38 +534,74 @@
const res = await fetch("/api/audio/status", { cache: "no-store" }); const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json(); const data = await res.json();
const status = data?.status || {}; const status = data?.status || {};
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) { if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250); pollTimer = setInterval(pollStatus, 250);
lastBeatSeq = Number(status.beat_seq || 0); lastBeatSeq = Number(status.beat_seq || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus(); await pollStatus();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
} }
} catch (e) { } catch (e) {
console.warn("audio resume poll check failed", e); console.warn("audio resume poll check failed", e);
} }
} }
/** /** Apply server-owned audio UI fields from status (device form, beat phase delay). */
* Apply browser-stored device fields only (GET /devices list); does not start detection. function applyServerAudioUiFields(status) {
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI). if (!status || typeof status !== "object") return;
*/ const run = status.audio_run;
async function applySavedAudioDeviceFormOnly() { if (run && typeof run === "object") {
const prefs = readRestorePrefs();
if (!prefs) return;
const ov = el("audio-device-override"); const ov = el("audio-device-override");
const sel = el("audio-device-select"); const sel = el("audio-device-select");
if (ov) ov.value = prefs.override || ""; 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 { try {
await refreshDevices(); await refreshDevices();
} catch (e) { } catch (e) {
console.warn("audio device list refresh failed", 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 () => { document.addEventListener("DOMContentLoaded", async () => {
bind(); bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning(); await resumePollingIfDetectorRunning();
await applySavedAudioDeviceFormOnly();
}); });
})(); })();

48
src/static/bundle_io.js Normal file
View File

@@ -0,0 +1,48 @@
/** Download/upload JSON bundles for profile, preset, and sequence import/export. */
window.downloadJsonFile = function downloadJsonFile(filename, data) {
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'bundle.json';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
window.pickJsonFile = function pickJsonFile() {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json,.json';
input.style.display = 'none';
document.body.appendChild(input);
input.addEventListener('change', () => {
const file = input.files && input.files[0];
input.remove();
if (!file) {
resolve(null);
return;
}
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => resolve(null);
reader.readAsText(file);
});
input.click();
});
};
window.parseJsonFileText = function parseJsonFileText(text) {
if (text == null || text === '') {
return null;
}
try {
return JSON.parse(text);
} catch (e) {
return null;
}
};

View File

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

View File

@@ -2,254 +2,21 @@ document.addEventListener('DOMContentLoaded', () => {
const openBtn = document.getElementById('led-tool-btn'); const openBtn = document.getElementById('led-tool-btn');
const modal = document.getElementById('led-tool-modal'); const modal = document.getElementById('led-tool-modal');
const closeBtn = document.getElementById('led-tool-close-btn'); const closeBtn = document.getElementById('led-tool-close-btn');
const refreshPortsBtn = document.getElementById('led-tool-refresh-ports-btn'); const iframe = document.getElementById('led-tool-iframe');
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');
if (!openBtn || !modal || !form || !portSelect || !outputEl || !messageEl) { if (!openBtn || !modal || !iframe) {
return; 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', () => { openBtn.addEventListener('click', () => {
iframe.src = '/led-tool/editor';
modal.classList.add('active'); modal.classList.add('active');
loadPorts();
}); });
if (closeBtn) { if (closeBtn) {
closeBtn.addEventListener('click', () => { closeBtn.addEventListener('click', () => {
modal.classList.remove('active'); 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

@@ -573,7 +573,12 @@ document.addEventListener('DOMContentLoaded', () => {
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: (() => {
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode);
}
return coercePresetInt(preset.n6);
})(),
}; };
}); });
if (!Object.keys(wirePresets).length) { if (!Object.keys(wirePresets).length) {

View File

@@ -4,6 +4,25 @@ let espnowSocketReady = false;
let espnowPendingMessages = []; let espnowPendingMessages = [];
let currentProfileIdCache = null; let currentProfileIdCache = null;
function coercePresetInt(v, def = 0) {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
}
/** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */
function presetWireN6(preset, def = 0) {
if (!preset || typeof preset !== 'object') {
return def;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
return coercePresetInt(preset.mode, def);
}
return coercePresetInt(preset.n6, def);
}
const getCurrentProfileId = async () => { const getCurrentProfileId = async () => {
try { try {
const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } });
@@ -243,6 +262,10 @@ document.addEventListener('DOMContentLoaded', () => {
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn'); 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) { if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
return; return;
@@ -297,7 +320,8 @@ document.addEventListener('DOMContentLoaded', () => {
patternConfig.parameter_mappings && patternConfig.parameter_mappings &&
typeof patternConfig.parameter_mappings === 'object' typeof patternConfig.parameter_mappings === 'object'
) { ) {
patternConfig = patternConfig.parameter_mappings; const { parameter_mappings: pm, data: _data, ...rest } = patternConfig;
patternConfig = { ...rest, ...pm };
} }
return patternConfig && typeof patternConfig === 'object' ? patternConfig : null; return patternConfig && typeof patternConfig === 'object' ? patternConfig : null;
}; };
@@ -311,6 +335,62 @@ document.addEventListener('DOMContentLoaded', () => {
return cfg.supports_manual !== false; return cfg.supports_manual !== false;
}; };
const getPatternModeOptions = (patternName) => {
const cfg = resolvePatternConfig(patternName);
if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) {
return null;
}
const entries = Object.entries(cfg.mode).filter(
([, label]) => typeof label === 'string' && label.trim(),
);
if (entries.length < 2) {
return null;
}
entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10));
return entries;
};
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;
}
presetModeGroup.hidden = !show;
presetModeGroup.style.display = show ? '' : 'none';
if (!show && presetModeInput) {
presetModeInput.innerHTML = '';
}
};
const presetStoredMode = (preset) => {
if (!preset || typeof preset !== 'object') {
return 0;
}
if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') {
const m = parseInt(String(preset.mode), 10);
return Number.isFinite(m) ? m : 0;
}
const n6 = parseInt(String(preset.n6), 10);
return Number.isFinite(n6) ? n6 : 0;
};
const updateManualBeatNVisibility = () => { const updateManualBeatNVisibility = () => {
if (!presetManualBeatNWrap) { if (!presetManualBeatNWrap) {
return; return;
@@ -711,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 // Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
@@ -734,7 +820,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// After values: show only mapped n params with labels from pattern.json; clear hidden inputs // After values: show only mapped n params with labels from pattern.json; clear hidden inputs
updatePresetNLabels(patternName); updatePresetNLabels(patternName, preset);
updateManualModeAvailability(); updateManualModeAvailability();
updatePresetEditorTabActionsVisibility(); updatePresetEditorTabActionsVisibility();
}; };
@@ -766,6 +852,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeInput) { if (presetManualModeInput) {
presetManualModeInput.checked = false; presetManualModeInput.checked = false;
} }
if (presetReverseInput) {
presetReverseInput.checked = false;
}
setPresetReverseFieldVisible(false);
if (presetManualBeatNInput) { if (presetManualBeatNInput) {
presetManualBeatNInput.value = '1'; presetManualBeatNInput.value = '1';
} }
@@ -793,10 +883,29 @@ document.addEventListener('DOMContentLoaded', () => {
return section ? section.dataset.zoneId : null; return section ? section.dataset.zoneId : null;
}; };
const updatePresetEditorTabActionsVisibility = () => { const updatePresetEditorTabActionsVisibility = async () => {
if (!presetRemoveFromTabButton) return; if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId); if (!currentEditTabId || !currentEditId) {
presetRemoveFromTabButton.hidden = !show; presetRemoveFromTabButton.hidden = true;
return;
}
try {
const tabRes = await fetch(`/zones/${currentEditTabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabRes.ok) {
presetRemoveFromTabButton.hidden = false;
return;
}
const tabData = await tabRes.json();
const allowed =
typeof window.zoneAllowsPresets === 'function'
? window.zoneAllowsPresets(tabData, currentEditTabId)
: true;
presetRemoveFromTabButton.hidden = !allowed;
} catch (e) {
presetRemoveFromTabButton.hidden = false;
}
}; };
const updateTabDefaultPreset = async (presetId) => { const updateTabDefaultPreset = async (presetId) => {
@@ -827,8 +936,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetEditorModal) { if (presetEditorModal) {
presetEditorModal.classList.add('active'); presetEditorModal.classList.add('active');
} }
const patternName = presetPatternInput ? presetPatternInput.value : '';
const modeBefore = patternSupportsModes(patternName)
? presetStoredMode({
mode: presetModeInput ? presetModeInput.value : undefined,
n6: getNumberInput('preset-n6-input'),
})
: 0;
loadPatterns().then(() => { loadPatterns().then(() => {
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : ''); updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore });
updateColorSectionVisibility(); updateColorSectionVisibility();
}); });
}; };
@@ -859,11 +975,27 @@ document.addEventListener('DOMContentLoaded', () => {
})(), })(),
}; };
// Always store numeric parameters as n1..n8. // Always store numeric parameters as n1..n8 (except n6 when pattern uses mode).
const modeEntries = patternSupportsModes(payload.pattern)
? getPatternModeOptions(payload.pattern)
: null;
const reverseField = patternSupportsReverse(payload.pattern);
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
if (modeEntries && nKey === 'n6') {
continue;
}
if (reverseField && nKey === 'n5') {
continue;
}
payload[nKey] = getNumberInput(`preset-${nKey}-input`); 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;
}
return payload; return payload;
}; };
@@ -950,30 +1082,8 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
const updatePresetNLabels = (patternName) => { const updatePresetNLabels = (patternName, presetForMode = null) => {
const rawPatternName = String(patternName || '').trim(); const patternConfig = resolvePatternConfig(patternName);
const normalizedPatternName = rawPatternName.endsWith('.py')
? rawPatternName.slice(0, -3)
: rawPatternName;
let patternConfig =
(cachedPatterns && cachedPatterns[rawPatternName]) ||
(cachedPatterns && cachedPatterns[normalizedPatternName]) ||
null;
if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') {
const lower = normalizedPatternName.toLowerCase();
const matchedKey = Object.keys(cachedPatterns).find(
(k) => String(k).toLowerCase() === lower,
);
if (matchedKey) {
patternConfig = cachedPatterns[matchedKey];
}
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') {
patternConfig = patternConfig.data;
}
if (patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object') {
patternConfig = patternConfig.parameter_mappings;
}
const labels = {}; const labels = {};
const visibleNKeys = new Set(); const visibleNKeys = new Set();
@@ -989,9 +1099,45 @@ 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);
presetModeInput.innerHTML = '';
modeEntries.forEach(([val, label]) => {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = label.trim();
presetModeInput.appendChild(opt);
});
const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0;
const modeStr = String(modeVal);
if ([...presetModeInput.options].some((o) => o.value === modeStr)) {
presetModeInput.value = modeStr;
} else if (presetModeInput.options.length) {
presetModeInput.selectedIndex = 0;
}
} else {
setPresetModeFieldVisible(false);
}
}
const hasPatternMeta = const hasPatternMeta =
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0; patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
const hasAnyNLabel = visibleNKeys.size > 0; const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries);
for (let i = 1; i <= 8; i++) { for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`; const nKey = `n${i}`;
@@ -1073,6 +1219,26 @@ document.addEventListener('DOMContentLoaded', () => {
void sendPresetViaEspNow(presetId, preset || {}, []); void sendPresetViaEspNow(presetId, preset || {}, []);
}); });
const exportButton = document.createElement('button');
exportButton.className = 'btn btn-secondary btn-small';
exportButton.textContent = 'Export';
exportButton.addEventListener('click', async () => {
try {
const response = await fetch(`/presets/${presetId}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Export failed');
}
const bundle = await response.json();
const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`preset-${safeName}.json`, bundle);
} catch (error) {
console.error('Export preset failed:', error);
alert('Failed to export preset.');
}
});
const deleteButton = document.createElement('button'); const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small'; deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete'; deleteButton.textContent = 'Delete';
@@ -1102,6 +1268,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(details); row.appendChild(details);
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(exportButton);
row.appendChild(sendButton); row.appendChild(sendButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
presetsList.appendChild(row); presetsList.appendChild(row);
@@ -1148,6 +1315,34 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetsCloseButton) { if (presetsCloseButton) {
presetsCloseButton.addEventListener('click', closeModal); presetsCloseButton.addEventListener('click', closeModal);
} }
const importPresetBtn = document.getElementById('import-preset-btn');
if (importPresetBtn) {
importPresetBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'preset') {
alert('Invalid preset bundle file.');
return;
}
try {
const response = await fetch('/presets/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadPresets();
} catch (error) {
console.error('Import preset failed:', error);
alert(error.message || 'Failed to import preset.');
}
});
}
if (presetsAddButton) { if (presetsAddButton) {
presetsAddButton.addEventListener('click', () => { presetsAddButton.addEventListener('click', () => {
clearForm(); clearForm();
@@ -1199,6 +1394,22 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
try {
const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (zoneCheck.ok) {
const zoneDoc = await zoneCheck.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(zoneDoc, zoneId)
) {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return;
}
}
} catch (e) {
console.warn('Could not verify zone content kind:', e);
}
// Load all presets // Load all presets
try { try {
const response = await fetch('/presets', { const response = await fetch('/presets', {
@@ -1327,11 +1538,10 @@ document.addEventListener('DOMContentLoaded', () => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsPresets === 'function' &&
? window.normalizeZoneContentKind(tabData) !window.zoneAllowsPresets(tabData, zoneId)
: null; ) {
if (kind === 'sequences') {
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
return; return;
} }
@@ -1697,14 +1907,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm(); clearForm();
}); });
const coercePresetInt = (v, def = 0) => {
if (typeof v === 'number' && Number.isFinite(v)) {
return v;
}
const t = parseInt(String(v), 10);
return Number.isFinite(t) ? t : def;
};
/** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */ /** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */
const coercePresetAuto = (preset) => { const coercePresetAuto = (preset) => {
if (!preset || typeof preset !== 'object') { if (!preset || typeof preset !== 'object') {
@@ -1826,7 +2028,7 @@ const sendPresetViaEspNow = async (
n3: coercePresetInt(preset.n3), n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4), n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5), n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6), n6: presetWireN6(preset),
manual_beat_n: coerceManualBeatN(preset), manual_beat_n: coerceManualBeatN(preset),
}, },
}, },
@@ -1929,7 +2131,7 @@ try {
// window may not exist in some environments; ignore. // window may not exist in some environments; ignore.
} }
// Store selected preset(s) per zone (multi-select; merge send order = click order, last wins on device). // Store selected preset per zone (single-select; one tile active, one driver push per click).
const zoneSelectedPresetIds = {}; const zoneSelectedPresetIds = {};
const zonePresetSelectionOrder = {}; const zonePresetSelectionOrder = {};
@@ -1956,19 +2158,21 @@ function getOrderedZonePresetSelection(zoneId) {
return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id))); return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id)));
} }
async function sendMergedZonePresetSelection(zoneId, tabData, allPresets) { /** Preset id that should show the tile outline (last click in selection order). */
const ids = getOrderedZonePresetSelection(zoneId); function getLastZonePresetSelectionId(zoneId) {
if (!ids.length) return; const order = getOrderedZonePresetSelection(zoneId);
for (let i = 0; i < ids.length; i += 1) { return order.length ? String(order[order.length - 1]) : null;
const pid = ids[i]; }
const preset = allPresets[pid];
if (!preset) continue; async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) {
const pid = String(presetId);
const body = (allPresets && allPresets[pid]) || preset;
if (!body) return;
const names = const names =
window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function'
? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid)
: []; : [];
await sendPresetViaEspNow(pid, preset, names, false, false, '2'); await sendPresetViaEspNow(pid, body, names, false, false, '2');
}
} }
// Store selected preset per zone // Store selected preset per zone
@@ -2053,6 +2257,12 @@ const savePresetGrid = async (zoneId, presetGrid) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
throw new Error('This zone is for sequences only.');
}
// Store as 2D grid // Store as 2D grid
tabData.presets = presetGrid; tabData.presets = presetGrid;
@@ -2147,9 +2357,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {}; const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
const ck = const ck =
typeof window.normalizeZoneContentKind === 'function' typeof window.effectiveZoneContentKind === 'function'
? window.effectiveZoneContentKind(tabData)
: typeof window.normalizeZoneContentKind === 'function'
? window.normalizeZoneContentKind(tabData) ? window.normalizeZoneContentKind(tabData)
: null; : 'presets';
// Get presets - support both 2D grid and flat array (for backward compatibility) // Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets; let presetGrid = tabData.presets;
@@ -2265,7 +2477,9 @@ const renderTabPresets = async (zoneId, options = {}) => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
if (preset) { if (preset) {
ensureZonePresetSelection(zoneId); ensureZonePresetSelection(zoneId);
const isSelected = zoneSelectedPresetIds[String(zoneId)].has(String(presetId)); const lastSelectedId = getLastZonePresetSelectionId(zoneId);
const isSelected =
lastSelectedId !== null && lastSelectedId === String(presetId);
const displayPreset = { const displayPreset = {
...preset, ...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
@@ -2285,7 +2499,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
}); });
} }
if (typeof window.appendZoneSequenceTiles === 'function' && ck !== 'presets') { if (
typeof window.appendZoneSequenceTiles === 'function' &&
(typeof window.zoneAllowsSequences !== 'function' ||
window.zoneAllowsSequences(tabData, zoneId))
) {
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
} }
} catch (error) { } catch (error) {
@@ -2311,7 +2529,9 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
} }
const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow'; const pat = (preset.pattern || '').toLowerCase();
const mode = presetWireN6(preset);
const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1);
const barColors = isRainbow const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
: colors; : colors;
@@ -2389,34 +2609,32 @@ const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, group
ensureZonePresetSelection(zoneId); ensureZonePresetSelection(zoneId);
const z = String(zoneId); const z = String(zoneId);
const set = zoneSelectedPresetIds[z]; const set = zoneSelectedPresetIds[z];
const order = zonePresetSelectionOrder[z];
const idStr = String(presetId); const idStr = String(presetId);
if (set.has(idStr)) { const wasSelected = set.has(idStr);
set.delete(idStr); set.clear();
zonePresetSelectionOrder[z] = order.filter((x) => String(x) !== idStr); zonePresetSelectionOrder[z] = [];
} else { if (!wasSelected) {
set.add(idStr); set.add(idStr);
order.push(idStr); zonePresetSelectionOrder[z] = [idStr];
} }
const outlinePresetId = getLastZonePresetSelectionId(zoneId);
if (presetsListEl) { if (presetsListEl) {
presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => { presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => {
const pid = rw.dataset.presetId; const pid = rw.dataset.presetId;
const btnEl = rw.querySelector('.preset-tile-main'); const btnEl = rw.querySelector('.preset-tile-main');
if (!btnEl || !pid) return; if (!btnEl || !pid) return;
if (set.has(String(pid))) btnEl.classList.add('active'); if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active');
else btnEl.classList.remove('active'); else btnEl.classList.remove('active');
}); });
} }
const orderList = getOrderedZonePresetSelection(zoneId); if (!wasSelected) {
if (orderList.length) { selectedPresets[zoneId] = idStr;
const lastPid = orderList[orderList.length - 1]; selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset;
selectedPresets[zoneId] = lastPid; void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets);
selectedPresetPayloads[zoneId] = (allPresets && allPresets[lastPid]) || preset;
} else { } else {
delete selectedPresets[zoneId]; delete selectedPresets[zoneId];
delete selectedPresetPayloads[zoneId]; delete selectedPresetPayloads[zoneId];
} }
void sendMergedZonePresetSelection(zoneId, tabData, allPresets);
}); });
if (canDrag) { if (canDrag) {
@@ -2526,6 +2744,13 @@ const removePresetFromTab = async (zoneId, presetId) => {
throw new Error('Failed to load zone'); throw new Error('Failed to load zone');
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
if (
typeof window.zoneAllowsPresets === 'function' &&
!window.zoneAllowsPresets(tabData, zoneId)
) {
alert('This zone is for sequences only.');
return;
}
// Normalize to flat array // Normalize to flat array
let flat = []; let flat = [];

View File

@@ -6,6 +6,7 @@ document.addEventListener("DOMContentLoaded", () => {
const newProfileInput = document.getElementById("new-profile-name"); const newProfileInput = document.getElementById("new-profile-name");
const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj"); const newProfileSeedDjInput = document.getElementById("new-profile-seed-dj");
const createProfileButton = document.getElementById("create-profile-btn"); const createProfileButton = document.getElementById("create-profile-btn");
const importProfileButton = document.getElementById("import-profile-btn");
if (!profilesButton || !profilesModal || !profilesList) { if (!profilesButton || !profilesModal || !profilesList) {
return; return;
@@ -101,6 +102,26 @@ document.addEventListener("DOMContentLoaded", () => {
} }
}); });
const exportButton = document.createElement("button");
exportButton.className = "btn btn-secondary btn-small";
exportButton.textContent = "Export";
exportButton.addEventListener("click", async () => {
try {
const response = await fetch(`/profiles/${profileId}/export`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error("Export failed");
}
const bundle = await response.json();
const safeName = ((profile && profile.name) || profileId).replace(/[^\w.-]+/g, "_");
window.downloadJsonFile(`profile-${safeName}.json`, bundle);
} catch (error) {
console.error("Export profile failed:", error);
alert("Failed to export profile.");
}
});
const cloneButton = document.createElement("button"); const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small"; cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone"; cloneButton.textContent = "Clone";
@@ -177,6 +198,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
if (editMode) { if (editMode) {
row.appendChild(exportButton);
row.appendChild(cloneButton); row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
} }
@@ -276,6 +298,60 @@ document.addEventListener("DOMContentLoaded", () => {
if (createProfileButton) { if (createProfileButton) {
createProfileButton.addEventListener("click", createProfile); createProfileButton.addEventListener("click", createProfile);
} }
const importProfile = async () => {
if (!isEditModeActive()) {
return;
}
const text = await window.pickJsonFile();
if (!text) {
return;
}
const bundle = window.parseJsonFileText(text);
if (!bundle || typeof bundle !== "object") {
alert("Invalid JSON file.");
return;
}
const defaultName =
(bundle.profile && bundle.profile.name) || "Imported profile";
const name = prompt("Profile name for import:", defaultName);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch("/profiles/import", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ bundle, name: trimmed, apply: true }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || "Import failed");
}
const data = await response.json();
const newProfileId = data.id || Object.keys(data).find((k) => k !== "id");
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
await loadProfiles();
await refreshTabsForActiveProfile();
} catch (error) {
console.error("Import profile failed:", error);
alert(error.message || "Failed to import profile.");
}
};
if (importProfileButton) {
importProfileButton.addEventListener("click", importProfile);
}
if (newProfileInput) { if (newProfileInput) {
newProfileInput.addEventListener("keypress", (event) => { newProfileInput.addEventListener("keypress", (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {

View File

@@ -1,14 +1,98 @@
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM. // 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() { function seqDebugEnabled() {
try { return sequenceDebugEnabled;
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
} catch {
return false;
}
} }
/** @type {ReturnType<typeof setInterval> | null} */ /** @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) { function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : []; const lgs = Array.isArray(sequenceDoc.lanes_group_ids) ? sequenceDoc.lanes_group_ids : [];
if (laneIndex < lgs.length) { if (laneIndex < lgs.length) {
@@ -155,7 +245,6 @@ function groupIdsForLaneStep(sequenceDoc, step, laneIndex, numLanes) {
function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) { function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
const raw = Array.isArray(doc && doc.lanes_group_ids) ? doc.lanes_group_ids : []; 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) { if (laneIndex < raw.length) {
const row = raw[laneIndex]; const row = raw[laneIndex];
if (Array.isArray(row)) { if (Array.isArray(row)) {
@@ -165,17 +254,10 @@ function buildLaneGroupIdsForEditor(doc, laneIndex, numLanes) {
if (numLanes > 1 && laneIndex >= raw.length) { if (numLanes > 1 && laneIndex >= raw.length) {
return []; return [];
} }
if (numLanes === 1) { return [];
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();
} }
function renderLaneGroupCheckboxes(groupsMap, selectedIds) { function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap'; wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;'; wrap.style.cssText = 'margin-bottom:0.6rem;';
@@ -183,15 +265,17 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds) {
hint.className = 'muted-text'; hint.className = 'muted-text';
hint.style.fontSize = '0.85em'; hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem'; 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); wrap.appendChild(hint);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'sequence-lane-groups'; row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;'; row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
const sel = new Set((selectedIds || []).map((x) => String(x))); const sel = new Set((selectedIds || []).map((x) => String(x)));
Object.keys(groupsMap) const zg = Array.isArray(zoneGroupIds) ? zoneGroupIds.map(String).filter(Boolean) : [];
.sort((a, b) => a.localeCompare(b)) const gidsToShow = zg.length
.forEach((gid) => { ? zg
: Object.keys(groupsMap).sort((a, b) => a.localeCompare(b));
gidsToShow.forEach((gid) => {
const g = groupsMap[gid]; const g = groupsMap[gid];
const gn = g && g.name ? String(g.name) : gid; const gn = g && g.name ? String(g.name) : gid;
const id = `seq-lg-${gid}-${Math.random().toString(36).slice(2)}`; 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). */ /** Match preset tiles: prefer DOM device list, then zone JSON (same as parseTabDeviceNames + computeZoneTargets). */
async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) { async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
const gids = Array.isArray(groupIds) ? groupIds.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; 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) { if (window.zonesManager && typeof window.zonesManager.resolveSequenceStepDeviceNames === 'function' && zoneDoc) {
return await window.zonesManager.resolveSequenceStepDeviceNames(zoneDoc, gids); 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.className = 'pattern-button preset-tile-main sequence-tile-main';
button.title = sequenceDoc.name || `Sequence ${sequenceId}`; 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'); const label = document.createElement('span');
label.textContent = sequenceDoc.name || sequenceId; label.textContent = sequenceDoc.name || sequenceId;
label.style.fontWeight = 'bold'; label.style.fontWeight = 'bold';
label.className = 'pattern-button-label'; label.className = 'pattern-button-label';
button.appendChild(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', () => { button.addEventListener('click', () => {
const strip = document.getElementById('presets-list-zone'); const strip = document.getElementById('presets-list-zone');
const clearActiveStrip = () => { const clearActiveStrip = () => {
@@ -454,11 +510,10 @@ async function addSequenceToTab(sequenceId, zoneId) {
const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!tabResponse.ok) throw new Error('Failed to load zone'); if (!tabResponse.ok) throw new Error('Failed to load zone');
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsSequences === 'function' &&
? window.normalizeZoneContentKind(tabData) !window.zoneAllowsSequences(tabData, zoneId)
: null; ) {
if (kind === 'presets') {
alert('This zone is for presets only. Add presets from the zone Edit menu instead.'); alert('This zone is for presets only. Add presets from the zone Edit menu instead.');
return; return;
} }
@@ -524,11 +579,10 @@ async function refreshEditTabSequencesUi(zoneId) {
const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); const zoneRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } });
if (!zoneRes.ok) throw new Error('zone'); if (!zoneRes.ok) throw new Error('zone');
const zone = await zoneRes.json(); const zone = await zoneRes.json();
const kind = if (
typeof window.normalizeZoneContentKind === 'function' typeof window.zoneAllowsSequences === 'function' &&
? window.normalizeZoneContentKind(zone) !window.zoneAllowsSequences(zone, zoneId)
: null; ) {
if (kind === 'presets') {
currentEl.innerHTML = currentEl.innerHTML =
'<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>'; '<span class="muted-text">This zone is for presets only. Sequences are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>'; addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -783,7 +837,7 @@ function renderSequenceStepRow(presetsMap, step) {
return row; return row;
} }
function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap) { function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, groupsMap, zoneGroupIds) {
const wrap = document.createElement('div'); const wrap = document.createElement('div');
wrap.className = 'sequence-lane'; wrap.className = 'sequence-lane';
wrap.dataset.laneIndex = String(laneIndex); wrap.dataset.laneIndex = String(laneIndex);
@@ -822,7 +876,7 @@ function renderSequenceLane(laneIndex, laneSteps, laneGroupIds, presetsMap, grou
head.appendChild(headBtns); head.appendChild(headBtns);
wrap.appendChild(head); wrap.appendChild(head);
wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds)); wrap.appendChild(renderLaneGroupCheckboxes(groupsMap, laneGroupIds, zoneGroupIds));
const stepsHost = document.createElement('div'); const stepsHost = document.createElement('div');
stepsHost.className = 'sequence-lane-steps'; stepsHost.className = 'sequence-lane-steps';
@@ -895,6 +949,24 @@ async function openSequenceEditor(sequenceId, existing) {
const presetsMap = presetsRes.ok ? await presetsRes.json() : {}; const presetsMap = presetsRes.ok ? await presetsRes.json() : {};
const groupsMap = await fetchGroupsMapSeq(); 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; let doc = existing;
if (sequenceEditorId) { if (sequenceEditorId) {
try { try {
@@ -928,11 +1000,11 @@ async function openSequenceEditor(sequenceId, existing) {
lanesHost.innerHTML = ''; lanesHost.innerHTML = '';
if (!lanes.some((l) => l.length > 0)) { if (!lanes.some((l) => l.length > 0)) {
const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1); const lg0 = buildLaneGroupIdsForEditor(doc, 0, 1);
lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap)); lanesHost.appendChild(renderSequenceLane(0, [], lg0, presetsMap, groupsMap, zoneGroupIds));
} else { } else {
lanes.forEach((laneSteps, i) => { lanes.forEach((laneSteps, i) => {
const lg = buildLaneGroupIdsForEditor(doc, i, lanes.length); 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(); refreshSequenceEditorLaneTitles();
@@ -1092,26 +1164,41 @@ async function loadSequencesModalList() {
const nSteps = ln.reduce((a, l) => a + l.length, 0); const nSteps = ln.reduce((a, l) => a + l.length, 0);
const nLanes = ln.filter((l) => l.length > 0).length || 1; const nLanes = ln.filter((l) => l.length > 0).length || 1;
title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`; title.textContent = `${doc.name || id}${nLanes} lane(s), ${nSteps} step(s)`;
const exportBtn = document.createElement('button');
exportBtn.type = 'button';
exportBtn.className = 'btn btn-secondary btn-small';
exportBtn.textContent = 'Export';
exportBtn.addEventListener('click', async () => {
try {
const response = await fetch(`/sequences/${id}/export`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) throw new Error('Export failed');
const bundle = await response.json();
const safeName = String(doc.name || id).replace(/[^\w.-]+/g, '_');
window.downloadJsonFile(`sequence-${safeName}.json`, bundle);
} catch (e) {
console.error(e);
alert('Failed to export sequence.');
}
});
const edit = document.createElement('button'); const edit = document.createElement('button');
edit.type = 'button'; edit.type = 'button';
edit.className = 'btn btn-secondary btn-small'; edit.className = 'btn btn-secondary btn-small';
edit.textContent = 'Edit'; edit.textContent = 'Edit';
edit.addEventListener('click', () => openSequenceEditor(id, doc)); edit.addEventListener('click', () => openSequenceEditor(id, doc));
row.appendChild(title); row.appendChild(title);
row.appendChild(exportBtn);
row.appendChild(edit); row.appendChild(edit);
listEl.appendChild(row); listEl.appendChild(row);
}); });
} }
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.stopZoneSequencePlayback = stopZoneSequencePlayback; window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */ /** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) { window.setSequenceDebug = function setSequenceDebug(on) {
try { sequenceDebugEnabled = !!on;
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);
}
console.log(seqDebugEnabled() ? 1 : 0); console.log(seqDebugEnabled() ? 1 : 0);
}; };
window.appendZoneSequenceTiles = appendZoneSequenceTiles; window.appendZoneSequenceTiles = appendZoneSequenceTiles;
@@ -1120,6 +1207,7 @@ window.addSequenceToTab = addSequenceToTab;
window.removeSequenceFromTab = removeSequenceFromTab; window.removeSequenceFromTab = removeSequenceFromTab;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
void initSequenceSwitchToggle();
const btn = document.getElementById('sequences-btn'); const btn = document.getElementById('sequences-btn');
const modal = document.getElementById('sequences-modal'); const modal = document.getElementById('sequences-modal');
const closeBtn = document.getElementById('sequences-close-btn'); const closeBtn = document.getElementById('sequences-close-btn');
@@ -1139,6 +1227,33 @@ document.addEventListener('DOMContentLoaded', () => {
openSequenceEditor(null, null); openSequenceEditor(null, null);
}); });
} }
const importSeqBtn = document.getElementById('import-sequence-btn');
if (importSeqBtn) {
importSeqBtn.addEventListener('click', async () => {
const text = await window.pickJsonFile();
if (!text) return;
const bundle = window.parseJsonFileText(text);
if (!bundle || bundle.kind !== 'sequence') {
alert('Invalid sequence bundle file.');
return;
}
try {
const response = await fetch('/sequences/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ bundle }),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.error || 'Import failed');
}
await loadSequencesModalList();
} catch (e) {
console.error(e);
alert(e.message || 'Failed to import sequence.');
}
});
}
const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn'); const openPresetsFromSeq = document.getElementById('sequences-open-presets-btn');
if (openPresetsFromSeq) { if (openPresetsFromSeq) {
openPresetsFromSeq.addEventListener('click', () => { openPresetsFromSeq.addEventListener('click', () => {

View File

@@ -106,7 +106,7 @@ header h1 {
font-weight: 600; 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 { .header-end {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -199,20 +199,43 @@ header h1 {
.audio-top-indicator { .audio-top-indicator {
display: none; display: none;
flex-direction: column; align-items: center;
align-items: stretch; gap: 0.35rem;
gap: 0.15rem;
padding: 0.25rem 0.55rem;
border: 1px solid #4a4a4a;
border-radius: 6px;
background-color: #1a1a1a;
min-width: 9rem; 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; display: inline-flex;
align-items: center; align-items: center;
gap: 0.4rem; 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 { .audio-top-indicator-extra {
@@ -226,10 +249,6 @@ header h1 {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.audio-top-indicator.audio-running {
display: inline-flex;
}
.audio-top-indicator-label { .audio-top-indicator-label {
font-size: 0.72rem; font-size: 0.72rem;
color: #bdbdbd; color: #bdbdbd;
@@ -245,16 +264,46 @@ header h1 {
} }
.audio-top-beat-readout { .audio-top-beat-readout {
font-size: 0.62rem; font-size: 0.75rem;
color: #b0bec5; color: #b0bec5;
line-height: 1.25; line-height: 1.25;
max-width: 12rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex: 1; flex: 1;
min-width: 0; min-width: 2rem;
text-align: left; 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 { .audio-top-indicator-subvalue {
@@ -264,16 +313,15 @@ header h1 {
text-align: right; text-align: right;
} }
.audio-top-indicator.flash { .audio-top-beat-sync.flash {
background-color: #ff5252; background-color: #ff5252;
border-color: #ff8a80; border-color: #ff8a80;
} }
.audio-top-indicator.flash .audio-top-indicator-value, .audio-top-beat-sync.flash .audio-top-indicator-value,
.audio-top-indicator.flash .audio-top-indicator-label, .audio-top-beat-sync.flash .audio-top-indicator-label,
.audio-top-indicator.flash .audio-top-indicator-subvalue, .audio-top-beat-sync.flash .audio-top-beat-readout,
.audio-top-indicator.flash .audio-top-indicator-extra, .audio-top-beat-sync.flash .audio-top-beat-readout::before {
.audio-top-indicator.flash .audio-top-beat-readout {
color: #fff; color: #fff;
} }
@@ -333,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
.zones-container { .zones-container {
background-color: transparent; background-color: transparent;
padding: 0.35rem 0 0; padding: 0;
flex: 0 0 auto; flex: 0 0 auto;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -598,6 +646,39 @@ body.preset-ui-run .edit-mode-only {
font-weight: 500; font-weight: 500;
} }
.preset-mode-field {
margin-top: 0.75rem;
margin-bottom: 0.25rem;
}
.preset-mode-field label {
display: block;
font-weight: 500;
margin-bottom: 0.35rem;
}
.preset-mode-input {
display: block;
width: 100%;
max-width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background-color: #3a3a3a;
color: #fff;
border: 1px solid #4a4a4a;
border-radius: 4px;
font-size: 1rem;
}
.preset-mode-input:focus {
outline: none;
border-color: #6a9fff;
}
#preset-editor-modal .preset-mode-field {
grid-column: 1 / -1;
}
.n-input { .n-input {
flex: 0 0 var(--n-input-width, 5ch); flex: 0 0 var(--n-input-width, 5ch);
width: var(--n-input-width, 5ch); width: var(--n-input-width, 5ch);
@@ -830,12 +911,41 @@ body.preset-ui-run .edit-mode-only {
min-width: 5rem; 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 { .audio-modal-beat-readout {
flex: 1; flex: 1;
min-width: 10rem; min-width: 10rem;
min-height: 2.25rem;
font-size: 0.85rem; font-size: 0.85rem;
line-height: 1.35; 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 { .audio-hit-type-readout {
@@ -970,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
white-space: normal; white-space: normal;
} }
.ui-mode-toggle--edit { .nav-slide-toggle-wrap {
background-color: #4a3f8f; display: inline-flex;
border: 1px solid #7b6fd6; 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; 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 */ /* Preset select buttons inside the zone grid */
@@ -1228,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
display: none; 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 { .header-menu-mobile {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.35rem;
margin-top: 0; margin-top: 0;
margin-left: 0;
} }
.header-end { .header-end {
@@ -1244,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
.header-end .audio-top-indicator { .header-end .audio-top-indicator {
min-width: 5rem; min-width: 5rem;
padding: 0.2rem 0.45rem;
flex-shrink: 0; flex-shrink: 0;
} }
.header-end .audio-top-beat-sync {
padding: 0.2rem 0.4rem;
min-height: 2rem;
gap: 0.3rem;
}
.btn { .btn {
font-size: 0.8rem; font-size: 0.8rem;
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;
@@ -1383,6 +1613,22 @@ body.preset-ui-run .edit-mode-only {
min-width: 8rem; min-width: 8rem;
} }
.zone-content-kind-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 1rem;
margin: 0.35rem 0 0.75rem;
}
.zone-content-kind-row label {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin: 0;
white-space: nowrap;
}
.zone-devices-label { .zone-devices-label {
display: block; display: block;
margin-top: 0.75rem; margin-top: 0.75rem;

View File

@@ -310,8 +310,7 @@ async function computeZonePresetUnionTargets(zoneDoc) {
} }
/** /**
* Device names for one sequence step. Empty stepGroupIds => all zone tab devices (``names`` only). * Device names for one sequence step. Only devices in checked lane groups (within the zone tab).
* Otherwise: lane groups intersected with that tab device list (not zone ``group_ids``).
*/ */
async function resolveSequenceStepDeviceNames(zone, stepGroupIds) { async function resolveSequenceStepDeviceNames(zone, stepGroupIds) {
const zoneT = await computeZoneNamesTargets(zone); 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) ? stepGroupIds.map((x) => String(x).trim()).filter((x) => x.length > 0)
: []; : [];
if (!gids.length) { if (!gids.length) {
return names.slice(); return [];
} }
const zoneMacSet = new Set( const zoneMacSet = new Set(
macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12), macs.map((m) => normalizeDeviceMac(m)).filter((m) => m.length === 12),
@@ -497,6 +496,30 @@ function normalizeZoneContentKind(zoneDoc) {
return null; return null;
} }
/** Display/save kind when ``content_kind`` is missing (legacy rows). */
function effectiveZoneContentKind(zoneDoc) {
const explicit = normalizeZoneContentKind(zoneDoc);
if (explicit) return explicit;
const seqIds = Array.isArray(zoneDoc && zoneDoc.sequence_ids)
? zoneDoc.sequence_ids.filter(Boolean)
: [];
const presetIds = tabPresetIdsInZoneDoc(zoneDoc || {});
if (seqIds.length > 0 && presetIds.length === 0) return 'sequences';
return 'presets';
}
/** @returns {boolean} */
function zoneAllowsPresets(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'presets';
}
/** @returns {boolean} */
function zoneAllowsSequences(zoneDoc, zoneId) {
void zoneId;
return effectiveZoneContentKind(zoneDoc) === 'sequences';
}
function applyZoneContentKindEditModal(kind) { function applyZoneContentKindEditModal(kind) {
const presetsBlock = document.getElementById('edit-zone-block-presets'); const presetsBlock = document.getElementById('edit-zone-block-presets');
const groupsBlock = document.getElementById('edit-zone-block-groups'); const groupsBlock = document.getElementById('edit-zone-block-groups');
@@ -504,17 +527,16 @@ function applyZoneContentKindEditModal(kind) {
const vis = (el, show) => { const vis = (el, show) => {
if (el) el.style.display = show ? '' : 'none'; if (el) el.style.display = show ? '' : 'none';
}; };
const k = kind === 'sequences' ? 'sequences' : 'presets';
vis(groupsBlock, true); vis(groupsBlock, true);
if (!kind) { vis(presetsBlock, k === 'presets');
vis(presetsBlock, true); vis(seqBlock, k === 'sequences');
vis(seqBlock, true);
return;
}
vis(presetsBlock, kind === 'presets');
vis(seqBlock, kind === 'sequences');
} }
window.normalizeZoneContentKind = normalizeZoneContentKind; window.normalizeZoneContentKind = normalizeZoneContentKind;
window.effectiveZoneContentKind = effectiveZoneContentKind;
window.zoneAllowsPresets = zoneAllowsPresets;
window.zoneAllowsSequences = zoneAllowsSequences;
// Load tabs list // Load tabs list
async function loadZones() { async function loadZones() {
@@ -572,11 +594,8 @@ function renderZonesList(tabs, tabOrder, currentZoneId) {
for (const zoneId of tabOrder) { for (const zoneId of tabOrder) {
const zone = tabs[zoneId]; const zone = tabs[zoneId];
if (zone) { if (zone) {
const activeClass = zoneId === currentZoneId ? 'active' : ''; const activeClass = String(zoneId) === String(currentZoneId) ? 'active' : '';
let disp = zone.name || `Zone ${zoneId}`; const disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
html += ` html += `
<button class="zone-button ${activeClass}" <button class="zone-button ${activeClass}"
data-zone-id="${zoneId}" data-zone-id="${zoneId}"
@@ -622,10 +641,7 @@ function renderZonesListModal(tabs, tabOrder, currentZoneId) {
row.dataset.zoneId = String(zoneId); row.dataset.zoneId = String(zoneId);
const label = document.createElement("span"); const label = document.createElement("span");
let disp = (zone && zone.name) || zoneId; const disp = zone.name || `Zone ${zoneId}`;
const kind = normalizeZoneContentKind(zone);
if (kind === 'presets') disp += ' · presets';
else if (kind === 'sequences') disp += ' · sequences';
label.textContent = disp; label.textContent = disp;
if (String(zoneId) === String(currentZoneId)) { if (String(zoneId) === String(currentZoneId)) {
label.textContent = `${disp}`; label.textContent = `${disp}`;
@@ -999,8 +1015,7 @@ async function refreshEditTabPresetsUi(zoneId) {
return; return;
} }
const tabData = await tabRes.json(); const tabData = await tabRes.json();
const kind = normalizeZoneContentKind(tabData); if (!zoneAllowsPresets(tabData, zoneId)) {
if (kind === 'sequences') {
currentEl.innerHTML = currentEl.innerHTML =
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>'; '<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
addEl.innerHTML = '<span class="muted-text">—</span>'; addEl.innerHTML = '<span class="muted-text">—</span>';
@@ -1138,8 +1153,17 @@ async function openEditZoneModal(zoneId, zone) {
}); });
renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap); renderZoneGroupsEditor(groupsEditor, window.__editTabGroupRows, groupsMap);
const kind = effectiveZoneContentKind(tabData);
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"); if (modal) modal.classList.add("active");
applyZoneContentKindEditModal(normalizeZoneContentKind(tabData)); applyZoneContentKindEditModal(kind);
await refreshEditTabPresetsUi(zoneId); await refreshEditTabPresetsUi(zoneId);
if (typeof window.refreshEditTabSequencesUi === "function") { if (typeof window.refreshEditTabSequencesUi === "function") {
await window.refreshEditTabSequencesUi(zoneId); await window.refreshEditTabSequencesUi(zoneId);
@@ -1152,16 +1176,34 @@ async function updateZone(zoneId, name, groupRows) {
const gids = Array.isArray(groupRows) const gids = Array.isArray(groupRows)
? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0) ? groupRows.map((r) => String(r.id || "").trim()).filter((x) => x.length > 0)
: []; : [];
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}`, { const response = await fetch(`/zones/${zoneId}`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
...existing,
name: name, name: name,
names: [], names: [],
group_ids: gids, group_ids: gids,
preset_group_ids: {}, preset_group_ids:
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
? existing.preset_group_ids
: {},
content_kind: lockedKind,
}) })
}); });
@@ -1170,6 +1212,9 @@ async function updateZone(zoneId, name, groupRows) {
// Reload tabs list // Reload tabs list
await loadZonesModal(); await loadZonesModal();
await loadZones(); await loadZones();
if (String(currentZoneId) === String(zoneId)) {
await loadZoneContent(zoneId);
}
// Close modal // Close modal
document.getElementById('edit-zone-modal').classList.remove('active'); document.getElementById('edit-zone-modal').classList.remove('active');
return true; return true;
@@ -1361,6 +1406,8 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
window.selectZone = selectZone;
// Export for use in other scripts // Export for use in other scripts
window.zonesManager = { window.zonesManager = {
loadZones, loadZones,

View File

@@ -9,18 +9,21 @@
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
<div class="header-end"> <div class="header-end">
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM"> <div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
<div class="audio-top-indicator-main"> <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 class="audio-top-indicator-label">BPM</span>
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</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> <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>
<div class="header-actions"> <div class="header-actions">
<div class="header-brightness-control"> <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="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 edit-mode-only" id="led-tool-btn">LED Tool</button>
<button class="btn btn-secondary" id="audio-btn">Audio</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 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> <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>
<div class="header-menu-mobile"> <div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button> <button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown"> <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> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<div class="menu-brightness-control"> <div class="menu-brightness-control">
<label for="menu-brightness-slider">Brightness</label> <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="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" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
<button type="button" data-target="audio-btn">Audio</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> <button type="button" data-target="help-btn">Help</button>
</div> </div>
</div> </div>
</div> </div>
<div class="zones-container">
<div id="zones-list">
Loading zones...
</div>
</div>
</header> </header>
<div class="main-content"> <div class="main-content">
@@ -83,11 +100,10 @@
<input type="text" id="new-zone-name" placeholder="Zone name"> <input type="text" id="new-zone-name" placeholder="Zone name">
<button class="btn btn-primary" id="create-zone-btn">Create</button> <button class="btn btn-primary" id="create-zone-btn">Create</button>
</div> </div>
<fieldset class="muted-text" style="margin:0.35rem 0 0.75rem;border:none;padding:0;"> <div class="zone-content-kind-row muted-text">
<legend style="font-size:0.85em;margin-bottom:0.35rem;">This zone is for</legend> <label><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label style="margin-right:1rem;"><input type="radio" name="new-zone-content-kind" value="presets" checked> Presets</label>
<label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label> <label><input type="radio" name="new-zone-content-kind" value="sequences"> Sequences</label>
</fieldset> </div>
<div id="zones-list-modal" class="profiles-list"></div> <div id="zones-list-modal" class="profiles-list"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-secondary" id="zones-close-btn">Close</button> <button class="btn btn-secondary" id="zones-close-btn">Close</button>
@@ -101,12 +117,9 @@
<h2>Edit Zone</h2> <h2>Edit Zone</h2>
<form id="edit-zone-form"> <form id="edit-zone-form">
<input type="hidden" id="edit-zone-id"> <input type="hidden" id="edit-zone-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
<label>Zone Name:</label> <label>Zone Name:</label>
<input type="text" id="edit-zone-name" placeholder="Enter zone name" required> <input type="text" id="edit-zone-name" placeholder="Enter zone name" required>
<p id="edit-zone-type-label" class="zone-content-kind-row muted-text" aria-live="polite"></p>
<div id="edit-zone-block-groups"> <div id="edit-zone-block-groups">
<label class="zone-devices-label">Device groups on this zone</label> <label class="zone-devices-label">Device groups on this zone</label>
<div id="edit-zone-groups-editor" class="zone-devices-editor"></div> <div id="edit-zone-groups-editor" class="zone-devices-editor"></div>
@@ -123,6 +136,10 @@
<label class="zone-presets-section-label">Add a sequence to this zone</label> <label class="zone-presets-section-label">Add a sequence to this zone</label>
<div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div> <div id="edit-zone-sequences-list" class="profiles-list edit-zone-presets-scroll"></div>
</div> </div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-zone-modal').classList.remove('active')">Close</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -134,6 +151,7 @@
<div class="profiles-actions"> <div class="profiles-actions">
<input type="text" id="new-profile-name" placeholder="Profile name"> <input type="text" id="new-profile-name" placeholder="Profile name">
<button class="btn btn-primary" id="create-profile-btn">Create</button> <button class="btn btn-primary" id="create-profile-btn">Create</button>
<button type="button" class="btn btn-secondary" id="import-profile-btn">Import</button>
</div> </div>
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;"> <label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
@@ -183,10 +201,6 @@
<h2>Edit device group</h2> <h2>Edit device group</h2>
<form id="edit-group-form"> <form id="edit-group-form">
<input type="hidden" id="edit-group-id"> <input type="hidden" id="edit-group-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
<label for="edit-group-name">Group name</label> <label for="edit-group-name">Group name</label>
<input type="text" id="edit-group-name" required autocomplete="off"> <input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;"> <label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
@@ -227,6 +241,10 @@
<label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label> <label for="edit-group-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small> <small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea> <textarea id="edit-group-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-group-close-btn">Cancel</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -301,6 +319,7 @@
<h2>Presets</h2> <h2>Presets</h2>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-add-btn">Add</button> <button class="btn btn-primary" id="preset-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-preset-btn">Import</button>
<button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button> <button class="btn btn-danger" id="preset-clear-device-btn">Clear Device Presets</button>
</div> </div>
<div id="presets-list" class="profiles-list"></div> <div id="presets-list" class="profiles-list"></div>
@@ -316,6 +335,7 @@
<h2>Sequences</h2> <h2>Sequences</h2>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button> <button type="button" class="btn btn-primary" id="sequence-add-btn">Add</button>
<button type="button" class="btn btn-secondary" id="import-sequence-btn">Import</button>
<button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button> <button type="button" class="btn btn-secondary" id="sequences-open-presets-btn">Presets</button>
</div> </div>
<div id="sequences-list" class="profiles-list"></div> <div id="sequences-list" class="profiles-list"></div>
@@ -342,12 +362,14 @@
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label> <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"> <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> <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>
<div id="sequence-editor-lanes"></div> <div id="sequence-editor-lanes"></div>
<div class="modal-actions" style="margin-top:0.75rem;">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
</div>
<div class="modal-actions preset-editor-modal-actions"> <div class="modal-actions preset-editor-modal-actions">
<button type="button" class="btn btn-secondary btn-small" id="sequence-editor-add-lane-btn">Add lane</button>
<button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button> <button type="button" class="btn btn-danger" id="sequence-editor-delete-btn">Delete</button>
<button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button> <button type="button" class="btn btn-primary" id="sequence-editor-save-btn">Save</button>
<button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button> <button type="button" class="btn btn-secondary" id="sequence-editor-close-btn">Close</button>
@@ -401,6 +423,16 @@
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span> <span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
</div> </div>
</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>
</div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
<label for="preset-n1-input" id="preset-n1-label">n1:</label> <label for="preset-n1-input" id="preset-n1-label">n1:</label>
@@ -611,22 +643,45 @@
<label>Current BPM</label> <label>Current BPM</label>
<div class="audio-bpm-row"> <div class="audio-bpm-row">
<div id="audio-bpm-value" class="audio-bpm-readout">--</div> <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> </div>
<div class="form-group"> <div class="form-group">
<label>Detected hit type</label> <label>Detected hit type</label>
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div> <div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
</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"> <div class="form-group">
<label>Flash on beat</label> <label>Flash on beat</label>
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div> <div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
</div> </div>
<div class="settings-section audio-settings-section">
<h3>Audio settings</h3>
<div class="form-group"> <div class="form-group">
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label> <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;"> <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> <small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
</div> </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"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button> <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> <button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
@@ -707,79 +762,14 @@
</div> </div>
</div> </div>
<!-- LED Tool Modal --> <!-- LED Tool Modal (led-tool/static settings editor) -->
<div id="led-tool-modal" class="modal"> <div id="led-tool-modal" class="modal">
<div class="modal-content"> <div class="modal-content" style="max-width: 960px; width: 95vw;">
<h2>LED Tool (USB)</h2> <div class="modal-actions" style="margin-bottom: 0.5rem;">
<p class="muted-text" style="margin-top: 0;">Configure a driver connected over USB using <code>led-tool</code>.</p> <h2 style="margin: 0; flex: 1;">LED Tool — device settings</h2>
<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> <button type="button" class="btn btn-secondary" id="led-tool-close-btn">Close</button>
</div> </div>
</form> <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>
<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> </div>
</div> </div>
@@ -789,6 +779,7 @@
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/led_tool.js"></script> <script src="/static/led_tool.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/bundle_io.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/zone_palette.js"></script> <script src="/static/zone_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
@@ -796,5 +787,6 @@
<script src="/static/sequences.js"></script> <script src="/static/sequences.js"></script>
<script src="/static/devices.js"></script> <script src="/static/devices.js"></script>
<script src="/static/audio.js"></script> <script src="/static/audio.js"></script>
<script src="/static/numpad.js"></script>
</body> </body>
</html> </html>

View File

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

View File

@@ -4,6 +4,12 @@ import os
import queue import queue
import threading import threading
import time import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
class AudioBeatDetector: class AudioBeatDetector:
@@ -13,6 +19,11 @@ class AudioBeatDetector:
self._stream = None self._stream = None
self._running = False self._running = False
self._stop_event = threading.Event() 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 = { self._status = {
"running": False, "running": False,
"bpm": None, "bpm": None,
@@ -20,6 +31,11 @@ class AudioBeatDetector:
"beat_seq": 0, "beat_seq": 0,
"beat_type": "unknown", "beat_type": "unknown",
"beat_type_confidence": 0.0, "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, "error": None,
"device": None, "device": None,
} }
@@ -100,6 +116,11 @@ class AudioBeatDetector:
"beat_seq": 0, "beat_seq": 0,
"beat_type": "unknown", "beat_type": "unknown",
"beat_type_confidence": 0.0, "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, "error": None,
"device": device, "device": device,
} }
@@ -111,6 +132,7 @@ class AudioBeatDetector:
self._thread.start() self._thread.start()
def stop(self): def stop(self):
self._stop_bpm_holdover()
with self._lock: with self._lock:
self._stop_event.set() self._stop_event.set()
t = self._thread t = self._thread
@@ -139,11 +161,159 @@ class AudioBeatDetector:
self._running = False self._running = False
self._thread = None self._thread = None
self._stream = None self._stream = None
self._pending_reset = False
self._status["running"] = False self._status["running"] = False
def status(self): def status(self):
with self._lock: 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): def _set_error(self, msg):
print(f"[audio] {msg}") print(f"[audio] {msg}")
@@ -152,7 +322,28 @@ class AudioBeatDetector:
self._status["running"] = False self._status["running"] = False
self._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() now = time.time()
with self._lock: with self._lock:
self._status["last_beat_ts"] = now self._status["last_beat_ts"] = now
@@ -160,6 +351,16 @@ class AudioBeatDetector:
self._status["beat_type"] = beat_type self._status["beat_type"] = beat_type
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0) self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1 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: try:
from util import sequence_playback as seq_pb from util import sequence_playback as seq_pb
@@ -210,15 +411,17 @@ class AudioBeatDetector:
flux_weight=0.3, flux_weight=0.3,
threshold_multiplier=1.35, threshold_multiplier=1.35,
ema_alpha=0.08, ema_alpha=0.08,
min_ioi_ms=85.0, min_ioi_ms=100.0,
bpm_window=8, bpm_window=8,
post_url="", post_url="",
aubio_method="default", aubio_method="default",
aubio_threshold=0.12, aubio_threshold=0.14,
silence_gate_db=-58.0, beats_per_bar=4,
) )
runtime = beat_mod.BeatDetectRuntime(args) runtime = beat_mod.BeatDetectRuntime(args)
runtime.setup(sample_rate=sample_rate) runtime.setup(sample_rate=sample_rate)
with self._lock:
self._runtime = runtime
hop_size = runtime.frame_size hop_size = runtime.frame_size
audio_q = queue.Queue(maxsize=64) audio_q = queue.Queue(maxsize=64)
@@ -243,10 +446,12 @@ class AudioBeatDetector:
stream.start() stream.start()
try: try:
while not self._stop_event.is_set(): while not self._stop_event.is_set():
self._process_pending_reset(runtime)
try: try:
frame = audio_q.get(timeout=0.1) frame = audio_q.get(timeout=0.1)
except queue.Empty: except queue.Empty:
continue continue
self._process_pending_reset(runtime)
if frame.shape[0] != hop_size: if frame.shape[0] != hop_size:
if frame.shape[0] > hop_size: if frame.shape[0] > hop_size:
frame = frame[:hop_size] frame = frame[:hop_size]
@@ -260,6 +465,11 @@ class AudioBeatDetector:
bpm, bpm,
beat_type=event.get("beat_type", "unknown"), beat_type=event.get("beat_type", "unknown"),
beat_type_confidence=event.get("beat_type_confidence", 0.0), 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: finally:
try: try:
@@ -280,6 +490,7 @@ class AudioBeatDetector:
with self._lock: with self._lock:
self._running = False self._running = False
self._status["running"] = False self._status["running"] = False
self._runtime = None
# Set from ``main`` so sequence playback can tell real audio from simulated beats. # 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")) return bool(d.status().get("running"))
except Exception: except Exception:
return False 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): except (OSError, json.JSONDecodeError, TypeError):
return {"enabled": False, "device": None} return {"enabled": False, "device": None}
if not isinstance(raw, dict): if not isinstance(raw, dict):
return {"enabled": False, "device": None} return {
"enabled": False,
"device": None,
"device_override": "",
"device_select": "",
}
enabled = bool(raw.get("enabled")) enabled = bool(raw.get("enabled"))
dev = raw.get("device", None) 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: def write_audio_run_state(
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start.""" *,
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() path = _db_path()
prev = read_audio_run_state() prev = read_audio_run_state()
if enabled: 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: 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: try:
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:

View File

@@ -299,6 +299,10 @@ def _apply_manual_beat_route_standalone_overlay(
return return
names = [str(n).strip() for n in device_names if str(n).strip()] names = [str(n).strip() for n in device_names if str(n).strip()]
with _route_lock: 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] = { _lane_manual[-1] = {
"device_names": names, "device_names": names,
"wire_preset_id": str(wire_preset_id).strip(), "wire_preset_id": str(wire_preset_id).strip(),
@@ -350,6 +354,11 @@ def set_sequence_manual_lane_route(
"manual_beat_n": mn, "manual_beat_n": mn,
"beat_counter": bc, "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() _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() _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( def sync_beat_route_from_push_sequence(
sequence: List[Any], sequence: List[Any],
target_macs: Optional[List[str]] = None, target_macs: Optional[List[str]] = None,
@@ -438,6 +490,7 @@ def sync_beat_route_from_push_sequence(
) )
else: else:
_apply_manual_beat_route(device_names, wire_preset_id, preset_body) _apply_manual_beat_route(device_names, wire_preset_id, preset_body)
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
return return
wire_id, body = _single_manual_wire_preset(merged_presets) wire_id, body = _single_manual_wire_preset(merged_presets)
@@ -547,6 +600,7 @@ def notify_beat_detected() -> None:
if not _lane_manual: if not _lane_manual:
return return
work = [] work = []
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
for key in sorted(_lane_manual.keys()): for key in sorted(_lane_manual.keys()):
e = _lane_manual[key] e = _lane_manual[key]
names = e.get("device_names") or [] names = e.get("device_names") or []
@@ -555,6 +609,8 @@ def notify_beat_detected() -> None:
pattern = str(e.get("pattern") or "") pattern = str(e.get("pattern") or "")
if pattern and not _pattern_supports_manual(pattern): if pattern and not _pattern_supports_manual(pattern):
continue continue
if e.pop("suppress_next_notify", False):
continue
try: try:
n = int(e.get("manual_beat_n") or 1) n = int(e.get("manual_beat_n") or 1)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -564,7 +620,12 @@ def notify_beat_detected() -> None:
c = int(e["beat_counter"]) c = int(e["beat_counter"])
if (c - 1) % n != 0: if (c - 1) % n != 0:
continue 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: if work:
_preset_session_beats += 1 _preset_session_beats += 1
if not work: if not work:

View File

@@ -43,6 +43,8 @@ import json
import struct import struct
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import wire_n6
BINARY_ENVELOPE_VERSION_1 = 1 BINARY_ENVELOPE_VERSION_1 = 1
BINARY_ENVELOPE_VERSION_2 = 2 BINARY_ENVELOPE_VERSION_2 = 2
HEADER_LEN = 5 HEADER_LEN = 5
@@ -108,7 +110,7 @@ def _pack_preset_dict(name: str, preset: Dict[str, Any]) -> bytes:
n3 = _clamp_i16(preset.get("n3", 0)) n3 = _clamp_i16(preset.get("n3", 0))
n4 = _clamp_i16(preset.get("n4", 0)) n4 = _clamp_i16(preset.get("n4", 0))
n5 = _clamp_i16(preset.get("n5", 0)) n5 = _clamp_i16(preset.get("n5", 0))
n6 = _clamp_i16(preset.get("n6", 0)) n6 = _clamp_i16(wire_n6(preset))
parts.append( parts.append(
struct.pack( struct.pack(
"<HBBhhhhhh", "<HBBhhhhhh",

View File

@@ -113,6 +113,21 @@ def resolve_preset_background_hex(preset_data, palette_colors=None):
return _hex_from_background_raw(bg_raw) return _hex_from_background_raw(bg_raw)
def wire_n6(preset_data, default=0):
"""Resolve style mode for the wire (``n6``); preset may store ``mode`` or ``n6``."""
if not isinstance(preset_data, dict):
return default
if preset_data.get("mode") is not None:
try:
return max(0, int(preset_data["mode"]))
except (TypeError, ValueError):
pass
try:
return max(0, int(preset_data.get("n6", default) or 0))
except (TypeError, ValueError):
return default
def build_preset_dict(preset_data, palette_colors=None): def build_preset_dict(preset_data, palette_colors=None):
""" """
Convert preset data to API-compliant format. Convert preset data to API-compliant format.
@@ -188,7 +203,7 @@ def build_preset_dict(preset_data, palette_colors=None):
"n3": preset_data.get("n3", 0), "n3": preset_data.get("n3", 0),
"n4": preset_data.get("n4", 0), "n4": preset_data.get("n4", 0),
"n5": preset_data.get("n5", 0), "n5": preset_data.get("n5", 0),
"n6": preset_data.get("n6", 0) "n6": wire_n6(preset_data),
} }
return preset return preset

441
src/util/profile_bundle.py Normal file
View File

@@ -0,0 +1,441 @@
"""Export/import profile bundles (profile, zones, presets, sequences, palette)."""
from __future__ import annotations
import copy
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
BUNDLE_VERSION = 1
KIND_PROFILE = "profile"
KIND_PRESET = "preset"
KIND_SEQUENCE = "sequence"
def _allocate_id(model, cache: Dict[str, int]) -> str:
if "next" not in cache:
max_id = max((int(k) for k in model.keys() if str(k).isdigit()), default=0)
cache["next"] = max_id + 1
next_id = str(cache["next"])
cache["next"] += 1
return next_id
def _palette_colors(palette_model, palette_id) -> List:
if not palette_id:
return []
try:
colors = palette_model.read(str(palette_id))
except Exception:
colors = None
if isinstance(colors, list):
return list(colors)
if isinstance(colors, dict) and isinstance(colors.get("colors"), list):
return list(colors["colors"])
return []
def _walk_preset_refs(value, out: Set[str]) -> None:
if isinstance(value, list):
for item in value:
_walk_preset_refs(item, out)
elif value is not None and value != "":
out.add(str(value))
def _preset_ids_in_zone(zone: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(zone, dict):
return ids
_walk_preset_refs(zone.get("presets"), ids)
_walk_preset_refs(zone.get("presets_flat"), ids)
if zone.get("default_preset") not in (None, ""):
ids.add(str(zone["default_preset"]))
return ids
def _preset_ids_in_sequence(seq: Dict[str, Any]) -> Set[str]:
ids: Set[str] = set()
if not isinstance(seq, dict):
return ids
for lane in seq.get("lanes") or []:
if not isinstance(lane, list):
continue
for step in lane:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
for step in seq.get("steps") or []:
if isinstance(step, dict) and step.get("preset_id") not in (None, ""):
ids.add(str(step["preset_id"]))
return ids
def _map_preset_container(
value,
id_map: Dict[str, str],
preset_cache: Dict[str, int],
new_profile_id: str,
new_presets: Dict[str, Dict[str, Any]],
presets_model,
) -> Any:
if isinstance(value, list):
return [
_map_preset_container(v, id_map, preset_cache, new_profile_id, new_presets, presets_model)
for v in value
]
if value is None:
return None
preset_id = str(value)
if preset_id in id_map:
return id_map[preset_id]
preset_data = presets_model.read(preset_id)
if not preset_data:
return None
new_preset_id = _allocate_id(presets_model, preset_cache)
clone_data = dict(preset_data)
clone_data["profile_id"] = str(new_profile_id)
new_presets[new_preset_id] = clone_data
id_map[preset_id] = new_preset_id
return new_preset_id
def _map_sequence_lanes(
seq: Dict[str, Any],
preset_id_map: Dict[str, str],
) -> Dict[str, Any]:
out = copy.deepcopy(seq)
lanes = out.get("lanes")
if isinstance(lanes, list):
for lane in lanes:
if not isinstance(lane, list):
continue
for step in lane:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
steps = out.get("steps")
if isinstance(steps, list):
for step in steps:
if not isinstance(step, dict):
continue
pid = step.get("preset_id")
if pid is not None and str(pid) in preset_id_map:
step["preset_id"] = preset_id_map[str(pid)]
return out
def export_profile_bundle(
profile_id: str,
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
) -> Dict[str, Any]:
source = profiles_model.read(profile_id)
if not source:
raise ValueError("Profile not found")
zone_ids = source.get("zones")
if not isinstance(zone_ids, list) or not zone_ids:
zone_ids = source.get("zone_order") or []
zone_ids = [str(z) for z in zone_ids if z is not None]
zones_out: Dict[str, Any] = {}
preset_ids: Set[str] = set()
sequence_ids: Set[str] = set()
for zid in zone_ids:
zone = zones_model.read(zid)
if not zone:
continue
zones_out[zid] = copy.deepcopy(zone)
preset_ids |= _preset_ids_in_zone(zone)
for sid in zone.get("sequence_ids") or []:
if sid is not None and str(sid).strip():
sequence_ids.add(str(sid))
sequences_out: Dict[str, Any] = {}
for sid in sequence_ids:
seq = sequences_model.read(sid)
if not seq or str(seq.get("profile_id")) != str(profile_id):
continue
sequences_out[sid] = copy.deepcopy(seq)
preset_ids |= _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for pid in preset_ids:
pdata = presets_model.read(pid)
if pdata and str(pdata.get("profile_id")) == str(profile_id):
presets_out[pid] = copy.deepcopy(pdata)
profile_doc = copy.deepcopy(source)
profile_doc.pop("palette", None)
return {
"version": BUNDLE_VERSION,
"kind": KIND_PROFILE,
"profile": profile_doc,
"palette": {"colors": _palette_colors(palette_model, source.get("palette_id"))},
"zones": zones_out,
"presets": presets_out,
"sequences": sequences_out,
}
def import_profile_bundle(
bundle: Dict[str, Any],
profiles_model,
zones_model,
presets_model,
sequences_model,
palette_model,
*,
name: Optional[str] = None,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("version") not in (BUNDLE_VERSION, str(BUNDLE_VERSION)):
raise ValueError("Unsupported bundle version")
if bundle.get("kind") not in (KIND_PROFILE, None):
raise ValueError("Not a profile bundle")
source_profile = bundle.get("profile")
if not isinstance(source_profile, dict):
raise ValueError("Bundle missing profile")
source_name = source_profile.get("name") or "Imported profile"
new_name = (name or source_name).strip() or source_name
profile_type = source_profile.get("type", "zones")
profile_cache: Dict[str, int] = {}
palette_cache: Dict[str, int] = {}
zone_cache: Dict[str, int] = {}
preset_cache: Dict[str, int] = {}
sequence_cache: Dict[str, int] = {}
new_profile_id = _allocate_id(profiles_model, profile_cache)
new_palette_id = _allocate_id(palette_model, palette_cache)
palette_in = bundle.get("palette") or {}
palette_colors = palette_in.get("colors") if isinstance(palette_in, dict) else []
if not isinstance(palette_colors, list):
palette_colors = []
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
new_zones: Dict[str, Dict[str, Any]] = {}
new_sequences: Dict[str, Dict[str, Any]] = {}
sequence_id_map: Dict[str, str] = {}
zones_in = bundle.get("zones") if isinstance(bundle.get("zones"), dict) else {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
sequences_in = bundle.get("sequences") if isinstance(bundle.get("sequences"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(new_profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_sid, sdata in sequences_in.items():
if not isinstance(sdata, dict):
continue
new_sid = _allocate_id(sequences_model, sequence_cache)
clone = _map_sequence_lanes(sdata, preset_id_map)
clone["profile_id"] = str(new_profile_id)
new_sequences[new_sid] = clone
sequence_id_map[str(old_sid)] = new_sid
source_zone_order = source_profile.get("zones")
if not isinstance(source_zone_order, list):
source_zone_order = list(zones_in.keys())
cloned_zone_ids: List[str] = []
for old_zid in source_zone_order:
zone = zones_in.get(str(old_zid))
if not isinstance(zone, dict):
continue
new_zid = _allocate_id(zones_model, zone_cache)
clone_data: Dict[str, Any] = {
"name": zone.get("name") or f"Zone {old_zid}",
"names": list(zone.get("names") or []),
}
mapped_presets = _map_preset_container(
zone.get("presets"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if mapped_presets is not None:
clone_data["presets"] = mapped_presets
extra = {
k: v
for k, v in zone.items()
if k not in ("name", "names", "presets")
}
if "presets_flat" in extra:
extra["presets_flat"] = _map_preset_container(
extra.get("presets_flat"),
preset_id_map,
preset_cache,
new_profile_id,
new_presets,
presets_model,
)
if "default_preset" in extra and extra["default_preset"] is not None:
old_dp = str(extra["default_preset"])
if old_dp in preset_id_map:
extra["default_preset"] = preset_id_map[old_dp]
if "sequence_ids" in extra and isinstance(extra.get("sequence_ids"), list):
extra["sequence_ids"] = [
sequence_id_map.get(str(s), str(s))
for s in extra["sequence_ids"]
if s is not None
]
clone_data.update(extra)
new_zones[new_zid] = clone_data
cloned_zone_ids.append(new_zid)
new_profile_data = {
"name": new_name,
"type": profile_type,
"zones": cloned_zone_ids,
"scenes": list(source_profile.get("scenes", []))
if isinstance(source_profile.get("scenes"), list)
else [],
"palette_id": str(new_palette_id),
}
palette_model[str(new_palette_id)] = list(palette_colors)
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
for zid, zdata in new_zones.items():
zones_model[zid] = zdata
for sid, sdata in new_sequences.items():
sequences_model[sid] = sdata
profiles_model[str(new_profile_id)] = new_profile_data
palette_model.save()
presets_model.save()
zones_model.save()
sequences_model.save()
profiles_model.save()
return str(new_profile_id), new_profile_data
def export_preset_bundle(preset_id: str, presets_model) -> Dict[str, Any]:
preset = presets_model.read(preset_id)
if not preset:
raise ValueError("Preset not found")
return {
"version": BUNDLE_VERSION,
"kind": KIND_PRESET,
"preset": copy.deepcopy(preset),
}
def import_preset_bundle(
bundle: Dict[str, Any],
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_PRESET:
raise ValueError("Not a preset bundle")
preset = bundle.get("preset")
if not isinstance(preset, dict):
raise ValueError("Bundle missing preset")
new_id = presets_model.create(profile_id)
data = copy.deepcopy(preset)
data["profile_id"] = str(profile_id)
presets_model.update(new_id, data)
return str(new_id), presets_model.read(new_id) or data
def export_sequence_bundle(
sequence_id: str,
sequences_model,
presets_model,
*,
profile_id: Optional[str] = None,
) -> Dict[str, Any]:
seq = sequences_model.read(sequence_id)
if not seq:
raise ValueError("Sequence not found")
if profile_id is not None and str(seq.get("profile_id")) != str(profile_id):
raise ValueError("Sequence not found")
pid = str(profile_id or seq.get("profile_id") or "")
preset_ids = _preset_ids_in_sequence(seq)
presets_out: Dict[str, Any] = {}
for old_pid in preset_ids:
pdata = presets_model.read(old_pid)
if pdata and (not pid or str(pdata.get("profile_id")) == pid):
presets_out[old_pid] = copy.deepcopy(pdata)
return {
"version": BUNDLE_VERSION,
"kind": KIND_SEQUENCE,
"sequence": copy.deepcopy(seq),
"presets": presets_out,
}
def import_sequence_bundle(
bundle: Dict[str, Any],
sequences_model,
presets_model,
profile_id: str,
) -> Tuple[str, Dict[str, Any]]:
if not isinstance(bundle, dict):
raise ValueError("Invalid bundle")
if bundle.get("kind") != KIND_SEQUENCE:
raise ValueError("Not a sequence bundle")
seq = bundle.get("sequence")
if not isinstance(seq, dict):
raise ValueError("Bundle missing sequence")
preset_cache: Dict[str, int] = {}
preset_id_map: Dict[str, str] = {}
new_presets: Dict[str, Dict[str, Any]] = {}
presets_in = bundle.get("presets") if isinstance(bundle.get("presets"), dict) else {}
for old_pid, pdata in presets_in.items():
if not isinstance(pdata, dict):
continue
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[str(old_pid)] = new_pid
for old_pid in _preset_ids_in_sequence(seq):
op = str(old_pid)
if op not in preset_id_map:
pdata = presets_model.read(op)
if pdata:
new_pid = _allocate_id(presets_model, preset_cache)
clone = copy.deepcopy(pdata)
clone["profile_id"] = str(profile_id)
new_presets[new_pid] = clone
preset_id_map[op] = new_pid
for pid, pdata in new_presets.items():
presets_model[pid] = pdata
if new_presets:
presets_model.save()
new_seq_id = sequences_model.create(profile_id)
mapped = _map_sequence_lanes(seq, preset_id_map)
mapped["profile_id"] = str(profile_id)
sequences_model.update(new_seq_id, mapped)
return str(new_seq_id), sequences_model.read(new_seq_id) or mapped

View File

@@ -24,6 +24,16 @@ _sim_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None _beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock() _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]: def _norm_mac(raw: Any) -> Optional[str]:
from models.device import normalize_mac 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( 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]: ) -> List[str]:
_ = zone_doc
lgs = sequence_doc.get("lanes_group_ids") lgs = sequence_doc.get("lanes_group_ids")
if isinstance(lgs, list) and lane_index < len(lgs): if isinstance(lgs, list) and lane_index < len(lgs):
for_lane = lgs[lane_index] for_lane = lgs[lane_index]
@@ -224,7 +239,7 @@ def _resolve_step_device_names(
) -> List[str]: ) -> List[str]:
z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups) z_names, z_macs = _compute_zone_targets(zone_doc, devices, groups)
if not step_group_ids: 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_mac_set = {m for m in (_norm_mac(x) for x in z_macs) if m}
zone_name_by_mac: Dict[str, str] = {} zone_name_by_mac: Dict[str, str] = {}
for i, m in enumerate(z_macs): 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) 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( def _split_device_names_for_lane(
all_names: List[str], all_names: List[str],
lane_index: int, lane_index: int,
num_lanes: int, num_lanes: int,
*, *,
partition_shared_zone: bool = True, partition_shared_zone: bool = False,
) -> List[str]: ) -> List[str]:
names = [n for n in all_names if n and str(n).strip()] names = [n for n in all_names if n and str(n).strip()]
if num_lanes <= 1 or not partition_shared_zone: if num_lanes <= 1 or not partition_shared_zone:
@@ -299,21 +331,6 @@ def _resolve_colors_with_palette_refs(
return out 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( def _display_preset_for_step(
preset_id: str, preset_id: str,
presets_map: Dict[str, Any], presets_map: Dict[str, Any],
@@ -348,6 +365,134 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
return inner 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: def _parse_zone_brightness_value(zone_doc: Any) -> int:
"""Zone slider value stored on the zone row (0255); default 255 if unset.""" """Zone slider value stored on the zone row (0255); default 255 if unset."""
from util.brightness_combine import clamp255 from util.brightness_combine import clamp255
@@ -363,37 +508,33 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
return 255 return 255
def _inner_wire_b_with_sequence_zone_brightness( async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
inner: Dict[str, Any], """Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
zone_doc: Dict[str, Any], from models.transport import get_current_sender
*, from util.brightness_combine import effective_brightness_for_mac
target_mac: Optional[str], from util.driver_delivery import deliver_json_messages
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,
)
out = dict(inner) sender = get_current_sender()
base = clamp255(out.get("b", 127)) 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) 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( eff = effective_brightness_for_mac(
settings_obj, settings_obj,
groups_model, groups_model,
devices_model, devices_model,
target_mac, mac,
zone_brightness=zb, zone_brightness=zb,
) )
out["b"] = multiply_brightness_factors([base, eff]) msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
else: await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
out["b"] = multiply_brightness_factors([base, zb])
return out
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]: 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: for step in lane:
if not isinstance(step, dict): if not isinstance(step, dict):
continue 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( device_names = _resolve_step_device_names(
zone_doc, gids, devices, groups, sequence_doc=sequence_doc 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 = _split_device_names_for_lane(
device_names, device_names,
lane_index, lane_index,
num_lanes, num_lanes,
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids( partition_shared_zone=_partition_devices_for_lane(
sequence_doc, lane_index num_lanes, lane_has_own_groups=lane_own, step_group_ids=gids
), ),
) )
if gids and not device_names: 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: if m and m not in seen:
seen.add(m) seen.add(m)
out.append(m) out.append(m)
if out:
return out return out
_, z_macs = _compute_zone_targets(zone_doc, devices, groups)
return list(z_macs)
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]: def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
lanes: List[List[Dict[str, Any]]] = ctx["lanes"] raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
presets_map: Dict[str, Any] = ctx["presets_map"] if isinstance(raw, bool):
palette_colors: List[Any] = ctx["palette_colors"] return raw
inner_by_wire: Dict[str, Any] = {} if raw is None:
for pid in _ordered_unique_preset_ids_from_lanes(lanes): return True
disp = _display_preset_for_step(pid, presets_map, palette_colors) if isinstance(raw, int):
if not disp: return raw != 0
continue if isinstance(raw, str):
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp) lo = raw.strip().lower()
return inner_by_wire if lo in ("false", "0", "no", "off"):
return False
if lo in ("true", "1", "yes", "on"):
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None: return True
"""Push all preset definitions used in the sequence once; step advances use select (auto) only.""" return True
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_auto(preset: Dict[str, Any]) -> bool: 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 [] 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( async def _send_lane(
lane_index: int, lane_index: int,
st: Dict[str, Any], st: Dict[str, Any],
ctx: Dict[str, Any], ctx: Dict[str, Any],
) -> None: ) -> None:
"""Apply the current step (select or manual route). Presets must already be on devices."""
lanes: List[List[Dict[str, Any]]] = ctx["lanes"] lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
sequence_doc: Dict[str, Any] = ctx["sequence_doc"] sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
presets_map: Dict[str, Any] = ctx["presets_map"] 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"] palette_colors: List[Any] = ctx["palette_colors"]
num_lanes = ctx["num_lanes"] devices = ctx["devices"]
if st.get("done"): if st.get("done"):
return return
@@ -660,15 +668,22 @@ async def _send_lane(
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors) display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
if not display_preset: if not display_preset:
return return
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes) num_lanes = int(ctx["num_lanes"])
device_names = _resolve_step_device_names( zone_doc = ctx["zone_doc"]
zone_doc, gids, devices, groups, sequence_doc=sequence_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 = _split_device_names_for_lane(
device_names, device_names,
lane_index, lane_index,
num_lanes, 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: if gids and not device_names:
return return
@@ -676,6 +691,7 @@ async def _send_lane(
from models.transport import get_current_sender from models.transport import get_current_sender
from util.beat_driver_route import ( from util.beat_driver_route import (
clear_sequence_manual_lane_route, clear_sequence_manual_lane_route,
mark_sequence_manual_lane_select_sent,
set_sequence_manual_lane_route, set_sequence_manual_lane_route,
) )
from util.driver_delivery import deliver_json_messages from util.driver_delivery import deliver_json_messages
@@ -688,20 +704,8 @@ async def _send_lane(
if not macs: if not macs:
return return
bulk = ctx.get("_sequence_wire_presets")
if isinstance(bulk, dict) and bulk:
auto = _coerce_auto(display_preset)
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) wire = str(preset_id)
auto = _coerce_auto(display_preset)
if auto: if auto:
clear_sequence_manual_lane_route(lane_index) clear_sequence_manual_lane_route(lane_index)
sel: Dict[str, Any] = {} sel: Dict[str, Any] = {}
@@ -713,19 +717,16 @@ async def _send_lane(
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":")) msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05) await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
else: else:
inner = _preset_inner_from_display_preset(display_preset)
set_sequence_manual_lane_route(lane_index, device_names, wire, inner) set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
return sel: Dict[str, Any] = {}
for n in device_names:
await _deliver_preset_for_devices( if n:
preset_id, sel[str(n)] = [wire]
display_preset, if sel:
device_names, msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
devices, await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
lane_index=lane_index, mark_sequence_manual_lane_select_sent(lane_index)
zone_doc=zone_doc,
settings_obj=ctx.get("settings"),
groups_model=groups,
)
async def _send_all_lanes(ctx: Dict[str, Any]) -> None: async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
@@ -745,7 +746,7 @@ def _build_ctx(
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
from models.device import Device from models.device import Device
from models.group import Group 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] lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
if not lanes: if not lanes:
@@ -764,9 +765,9 @@ def _build_ctx(
"presets_map": presets_map, "presets_map": presets_map,
"devices": devices, "devices": devices,
"groups": groups, "groups": groups,
"settings": Settings(), "settings": get_settings(),
"palette_colors": palette_colors, "palette_colors": palette_colors,
"loop": True, "loop": _coerce_loop(sequence_doc),
"advance_mode": "beats", "advance_mode": "beats",
} }
@@ -897,7 +898,294 @@ async def process_active_beat_advance() -> None:
else: else:
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1 ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
if all(s.get("done") for s in lane_states): 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: def push_thread_beat() -> None:
@@ -920,6 +1208,12 @@ async def beat_consumer_loop() -> None:
from util.beat_driver_route import notify_beat_detected from util.beat_driver_route import notify_beat_detected
for _ in range(n): 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: try:
await process_active_beat_advance() await process_active_beat_advance()
except Exception as e: except Exception as e:
@@ -981,20 +1275,10 @@ async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -
return return
if ad_mod.shared_beat_detector_running(): if ad_mod.shared_beat_detector_running():
continue continue
_mark_simulated_beat_phase()
push_thread_beat() 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: 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).""" """If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
sid = str(sequence_id).strip() sid = str(sequence_id).strip()
@@ -1007,6 +1291,10 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
cur = ctx.get("sequence_id") cur = ctx.get("sequence_id")
if cur is None or str(cur).strip() != sid: if cur is None or str(cur).strip() != sid:
return False return False
try:
loop = asyncio.get_running_loop()
loop.create_task(stop_playback(clear_devices=True))
except RuntimeError:
stop() stop()
return True return True
@@ -1016,6 +1304,29 @@ async def start(
sequence_id: str, sequence_id: str,
profile_id: str, profile_id: str,
play_options: Optional[Dict[str, Any]] = None, 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: ) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token global _beat_run, _sim_beat_task, _sim_beat_token
from models.preset import Preset from models.preset import Preset
@@ -1052,14 +1363,11 @@ async def start(
ctx["zone_id"] = str(zone_id) ctx["zone_id"] = str(zone_id)
ctx["sequence_loop_beat"] = 0 ctx["sequence_loop_beat"] = 0
await _deliver_sequence_presets_bulk(ctx) _reset_beat_side_effects()
await _prime_all_lanes(ctx)
from util.beat_driver_route import update_beat_route await _deliver_zone_brightness_for_sequence(ctx)
update_beat_route({"enabled": False})
with _beat_run_lock: with _beat_run_lock:
_beat_run = ctx _beat_run = ctx
await _send_all_lanes(ctx)
bpm = _coerce_simulated_bpm(sequence_doc, play_options) bpm = _coerce_simulated_bpm(sequence_doc, play_options)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()

View File

@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
default=0.12, default=0.12,
help="Aubio detection threshold", 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() return parser.parse_args()
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
return 60.0 / float(np.median(valid)) 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): def _load_aubio_if_needed(mode: str):
if mode == "custom": if mode == "custom":
return None return None
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
) )
self.last_trigger_s = 0.0 self.last_trigger_s = 0.0
self.debounce_s = float(args.min_ioi_ms) / 1000.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): def setup(self, sample_rate: int):
self.sample_rate = int(sample_rate) self.sample_rate = int(sample_rate)
@@ -192,6 +323,9 @@ class BeatDetectRuntime:
self.beat_times.clear() self.beat_times.clear()
self.tempo = None self.tempo = None
if self.aubio is not None: if self.aubio is not None:
self._init_aubio_tempo(win_size)
def _init_aubio_tempo(self, win_size: int):
self.tempo = self.aubio.tempo( self.tempo = self.aubio.tempo(
self.args.aubio_method, win_size, self.frame_size, self.sample_rate self.args.aubio_method, win_size, self.frame_size, self.sample_rate
) )
@@ -200,6 +334,27 @@ class BeatDetectRuntime:
if hasattr(self.tempo, "set_minioi_ms"): if hasattr(self.tempo, "set_minioi_ms"):
self.tempo.set_minioi_ms(float(self.args.min_ioi_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): def _classify_hit(self, mag: np.ndarray):
total = float(np.mean(mag) + 1e-9) total = float(np.mean(mag) + 1e-9)
kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0 kick = float(np.mean(mag[self.kick_mask])) / total if np.any(self.kick_mask) else 0.0
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
f32 = frame.astype(np.float32) f32 = frame.astype(np.float32)
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12)) rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
db = 20.0 * np.log10(max(rms, 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) mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
band_energy = float(np.mean(mag[self.band_mask])) band_energy = float(np.mean(mag[self.band_mask]))
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag))) flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
should_trigger = aubio_hit should_trigger = aubio_hit
else: else:
should_trigger = custom_hit or aubio_hit 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: if not should_trigger:
return None return None
self.last_trigger_s = now_s self.last_trigger_s = now_s
self.beat_times.append(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) strength = score / max(1e-9, self.baseline)
beat_type, beat_type_conf = self._classify_hit(mag) 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": if self.args.mode == "custom":
src = "custom" src = "custom"
elif self.args.mode == "aubio": elif self.args.mode == "aubio":
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
"beat_type": beat_type, "beat_type": beat_type,
"beat_type_confidence": beat_type_conf, "beat_type_confidence": beat_type_conf,
"db": db, "db": db,
**phase,
} }

View File

@@ -25,18 +25,20 @@ def test_preset():
print("\nTesting update preset") print("\nTesting update preset")
update_data = { update_data = {
"name": "test_preset", "name": "test_preset",
"pattern": "on", "pattern": "colour_cycle",
"colors": ["#FF0000", "#00FF00"], "colors": ["#FF0000", "#00FF00"],
"delay": 100, "delay": 100,
"brightness": 127, "brightness": 127,
"n1": 10, "n1": 10,
"n2": 20 "n2": 20,
"mode": 1,
} }
result = presets.update(preset_id, update_data) result = presets.update(preset_id, update_data)
assert result is True assert result is True
updated = presets.read(preset_id) updated = presets.read(preset_id)
assert updated["name"] == "test_preset" assert updated["name"] == "test_preset"
assert updated["pattern"] == "on" assert updated["pattern"] == "colour_cycle"
assert updated["mode"] == 1
assert updated["delay"] == 100 assert updated["delay"] == 100
print("\nTesting list presets") print("\nTesting list presets")

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

@@ -29,6 +29,21 @@ def test_pack_parse_v2_brightness_only():
assert data == {"v": "1", "b": 128} assert data == {"v": "1", "b": 128}
def test_pack_parse_v2_mode_maps_to_n6():
raw = pack_binary_envelope_v2(
presets={
"m": {
"p": "meteor",
"c": ["#aabbcc"],
"mode": 2,
"n6": 0,
}
},
)
data = parse_binary_envelope_v2(raw)
assert data["presets"]["m"]["n6"] == 2
def test_pack_parse_v2_full(): def test_pack_parse_v2_full():
raw = pack_binary_envelope_v2( raw = pack_binary_envelope_v2(
presets={ presets={

View File

@@ -50,6 +50,17 @@ def _find_id_by_field(list_resp_json: Dict[str, Any], field: str, value: str) ->
raise AssertionError(f"Could not find id for {field}={value!r}") raise AssertionError(f"Could not find id for {field}={value!r}")
def _create_and_apply_profile(c: requests.Session, base_url: str) -> str:
"""Sequences/scenes/presets need an active profile in session."""
unique_profile_name = f"pytest-profile-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles", json={"name": unique_profile_name})
assert resp.status_code == 201
profile_id = next(iter(resp.json().keys()))
resp = c.post(f"{base_url}/profiles/{profile_id}/apply")
assert resp.status_code == 200
return str(profile_id)
def _start_microdot_server(app: Microdot, host: str, port: int): def _start_microdot_server(app: Microdot, host: str, port: int):
""" """
Start Microdot server on a background thread. Start Microdot server on a background thread.
@@ -341,19 +352,27 @@ def test_settings_controller(server):
) )
assert resp.status_code == 400 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 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 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 assert resp.status_code == 200
resp = c.get(f"{base_url}/settings") resp = c.get(f"{base_url}/settings")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json().get("global_brightness") == 42 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 assert resp.status_code == 400
@@ -474,6 +493,36 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
resp = c.delete(f"{base_url}/zones/{zone_id}") resp = c.delete(f"{base_url}/zones/{zone_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/profiles/{profile_id}/export")
assert resp.status_code == 200
bundle = resp.json()
assert bundle.get("kind") == "profile"
assert isinstance(bundle.get("presets"), dict)
import_name = f"pytest-imported-{uuid.uuid4().hex[:8]}"
resp = c.post(
f"{base_url}/profiles/import",
json={"bundle": bundle, "name": import_name, "apply": False},
)
assert resp.status_code == 201
imported_profile_id = resp.json().get("id") or next(
k for k in resp.json().keys() if k != "id"
)
resp = c.delete(f"{base_url}/profiles/{imported_profile_id}")
assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{first_preset_id}/export")
assert resp.status_code == 200
assert resp.json().get("kind") == "preset"
resp = c.post(
f"{base_url}/presets/import",
json={"bundle": resp.json()},
)
assert resp.status_code == 201
imported_preset_id = next(iter(resp.json().keys()))
resp = c.delete(f"{base_url}/presets/{imported_preset_id}")
assert resp.status_code == 200
# Profile clone + update endpoints. # Profile clone + update endpoints.
clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}" clone_name = f"pytest-profile-clone-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name}) resp = c.post(f"{base_url}/profiles/{profile_id}/clone", json={"name": clone_name})
@@ -508,6 +557,8 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
base_url: str = server["base_url"] base_url: str = server["base_url"]
sender: DummySender = server["sender"] sender: DummySender = server["sender"]
_create_and_apply_profile(c, base_url)
# Groups. # Groups.
unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}" unique_group_name = f"pytest-group-{uuid.uuid4().hex[:8]}"
resp = c.post(f"{base_url}/groups", json={"name": unique_group_name}) resp = c.post(f"{base_url}/groups", json={"name": unique_group_name})
@@ -715,6 +766,13 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
assert resp.status_code == 200 assert resp.status_code == 200
definitions = resp.json() definitions = resp.json()
assert isinstance(definitions, dict) assert isinstance(definitions, dict)
assert "colour_cycle" in definitions
cc_mode = definitions["colour_cycle"].get("mode")
assert isinstance(cc_mode, dict)
assert "0" in cc_mode and "1" in cc_mode
assert "blink" in definitions
blink_mode = definitions["blink"].get("mode")
assert not isinstance(blink_mode, dict) or len(blink_mode) < 2
pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}" pattern_id = f"pytest_pattern_{uuid.uuid4().hex[:8]}"
resp = c.post( resp = c.post(

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,51 @@
"""Preset style mode: ``mode`` field, wire ``n6``, and pattern.json metadata."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(0, str(PROJECT_ROOT / "led-driver" / "src"))
from patterns.pattern_modes import style_mode # noqa: E402
from preset import Preset # noqa: E402
from util.espnow_message import build_preset_dict, wire_n6 # noqa: E402
def test_wire_n6_prefers_mode_over_n6():
assert wire_n6({"mode": 2, "n6": 0}) == 2
assert wire_n6({"n6": 1}) == 1
assert wire_n6({}) == 0
def test_build_preset_dict_maps_mode_to_n6():
wire = build_preset_dict({"pattern": "meteor", "mode": 2, "colors": ["#ffffff"]})
assert wire["n6"] == 2
assert wire["p"] == "meteor"
def test_preset_edit_accepts_mode_alias():
p = Preset({"p": "colour_cycle", "mode": 1, "d": 100, "c": [(255, 255, 255)]})
assert p.n6 == 1
def test_style_mode_reads_mode_and_legacy_pattern_id():
p = Preset({"p": "colour_cycle", "mode": 0, "d": 100, "c": [(255, 0, 0)]})
assert style_mode(p, 0, {"rainbow": 1}) == 0
legacy = Preset({"p": "rainbow", "d": 100, "c": [(255, 0, 0)]})
assert style_mode(legacy, 0, {"rainbow": 1}) == 1
def test_pattern_json_defines_mode_for_merged_patterns():
path = PROJECT_ROOT / "db" / "pattern.json"
definitions = json.loads(path.read_text(encoding="utf-8"))
for name in ("colour_cycle", "chase", "aurora", "meteor", "particles", "sparkle"):
assert name in definitions, name
mode = definitions[name].get("mode")
assert isinstance(mode, dict), name
assert len(mode) >= 2, name
blink = definitions.get("blink", {})
assert "mode" not in blink or not isinstance(blink.get("mode"), dict) or len(blink.get("mode", {})) < 2

View File

@@ -0,0 +1,133 @@
"""Unit tests for profile/preset/sequence bundle import/export."""
import json
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from models.pallet import Palette # noqa: E402
from models.preset import Preset # noqa: E402
from models.profile import Profile # noqa: E402
from models.sequence import Sequence # noqa: E402
from models.zone import Zone # noqa: E402
from util.profile_bundle import ( # noqa: E402
export_preset_bundle,
export_profile_bundle,
export_sequence_bundle,
import_preset_bundle,
import_profile_bundle,
import_sequence_bundle,
)
def _fresh_models(tmp_path, monkeypatch):
import models.model as model_mod
db = tmp_path / "db"
db.mkdir()
monkeypatch.setattr(model_mod, "_db_dir", lambda: str(db))
for cls in (Profile, Zone, Preset, Sequence, Palette):
if hasattr(cls, "_instance"):
delattr(cls, "_instance")
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
palette = Palette()
return profiles, zones, presets, sequences, palette
def test_profile_export_import_round_trip(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("Source")
zid = zones.create(name="main")
preset_id = presets.create(pid)
presets.update(
preset_id,
{
"name": "Test preset",
"pattern": "blink",
"colors": ["#ff0000"],
"brightness": 200,
"delay": 50,
},
)
zones.update(
zid,
{
"presets_flat": [str(preset_id)],
"default_preset": str(preset_id),
},
)
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{
"name": "Beat seq",
"lanes": [[{"preset_id": str(preset_id), "group_ids": [], "beats": 2}]],
"lanes_group_ids": [[]],
},
)
zones.update(zid, {"sequence_ids": [str(seq_id)]})
profiles.update(pid, {"zones": [str(zid)]})
palette_id = profiles.read(pid)["palette_id"]
palette.update(palette_id, {"colors": ["#112233", "#445566"]})
bundle = export_profile_bundle(
str(pid), profiles, zones, presets, sequences, palette
)
assert bundle["kind"] == "profile"
assert str(preset_id) in bundle["presets"]
assert str(seq_id) in bundle["sequences"]
assert bundle["palette"]["colors"] == ["#112233", "#445566"]
new_pid, _ = import_profile_bundle(
bundle, profiles, zones, presets, sequences, palette, name="Imported"
)
assert new_pid != str(pid)
found = [
presets.read(k)
for k in presets.list()
if isinstance(presets.read(k), dict)
and str(presets.read(k).get("profile_id")) == str(new_pid)
and presets.read(k).get("name") == "Test preset"
]
assert found
def test_preset_export_import(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Solo", "pattern": "on", "colors": ["#00ff00"]})
bundle = export_preset_bundle(str(preset_id), presets)
assert bundle["kind"] == "preset"
new_id, data = import_preset_bundle(bundle, presets, str(pid))
assert new_id != str(preset_id)
assert data["name"] == "Solo"
def test_sequence_export_import_with_presets(tmp_path, monkeypatch):
profiles, zones, presets, sequences, palette = _fresh_models(tmp_path, monkeypatch)
pid = profiles.create("P")
preset_id = presets.create(pid)
presets.update(preset_id, {"name": "Step", "pattern": "off"})
seq_id = sequences.create(pid)
sequences.update(
seq_id,
{"name": "S", "lanes": [[{"preset_id": str(preset_id), "beats": 1}]]},
)
bundle = export_sequence_bundle(str(seq_id), sequences, presets, profile_id=str(pid))
assert str(preset_id) in bundle["presets"]
new_seq_id, doc = import_sequence_bundle(bundle, sequences, presets, str(pid))
assert new_seq_id != str(seq_id)
assert doc["lanes"][0][0]["preset_id"] != str(preset_id)

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") == []