Compare commits
5 Commits
879db2a7df
...
ef15c54593
| Author | SHA1 | Date | |
|---|---|---|---|
| ef15c54593 | |||
| 301e1c64bf | |||
| c286e504eb | |||
| 964cfc6d91 | |||
| 7ecb5c3b3e |
@@ -12,6 +12,7 @@
|
||||
"supports_manual": true
|
||||
},
|
||||
"colour_cycle": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Step rate",
|
||||
"mode": {
|
||||
"0": "Scroll palette gradient",
|
||||
@@ -29,6 +30,7 @@
|
||||
"supports_manual": false
|
||||
},
|
||||
"chase": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Colour 1 Length",
|
||||
"n2": "Colour 2 Length",
|
||||
"n3": "Step 1",
|
||||
@@ -155,6 +157,7 @@
|
||||
"supports_manual": true
|
||||
},
|
||||
"aurora": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Band count (0) or spatial period LEDs (1)",
|
||||
"n2": "Shimmer (0) or blend strength (1)",
|
||||
"n3": "Unused (0) or drift speed (1)",
|
||||
@@ -169,6 +172,7 @@
|
||||
"supports_manual": true
|
||||
},
|
||||
"icicles": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Anchor spacing (LEDs)",
|
||||
"n2": "Max icicle length (LEDs)",
|
||||
"n3": "Phase step per refresh",
|
||||
@@ -179,6 +183,7 @@
|
||||
"supports_manual": true
|
||||
},
|
||||
"blizzard": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Flake density",
|
||||
"n2": "Fall speed",
|
||||
"n3": "Wind (128 = centred; lower/raise for drift bias)",
|
||||
@@ -227,6 +232,7 @@
|
||||
"supports_manual": false
|
||||
},
|
||||
"meteor": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Tail length (0–1) or eye width (2)",
|
||||
"n2": "Speed (LEDs per frame)",
|
||||
"n3": "Fade amount (0), comet gap (1), or end pause frames (2)",
|
||||
@@ -242,6 +248,7 @@
|
||||
"supports_manual": true
|
||||
},
|
||||
"particles": {
|
||||
"supports_reverse": true,
|
||||
"n1": "Flake density (0) or spawn rate (1)",
|
||||
"n2": "Fall speed (LEDs per frame)",
|
||||
"n3": "Unused (0) or streak length (1)",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{}
|
||||
{"1": {"name": "Pulse (manual)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}, "2": {"name": "Off (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "3": {"name": "On (1 beat)", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "4": {"name": "Rainbow \u2192 transition \u2192 off", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "3", "beats": 4}, {"preset_id": "4", "beats": 4}, {"preset_id": "2", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 100, "sequence_transition": 500, "loop": true}, "5": {"name": "Manual pulse + chase", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "6", "beats": 2}, {"preset_id": "5", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "6": {"name": "RGB solid cycle", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "11", "beats": 2}, {"preset_id": "12", "beats": 2}, {"preset_id": "9", "beats": 2}, {"preset_id": "10", "beats": 2}], "step_duration_ms": 3000, "simulated_bpm": 90, "sequence_transition": 500, "loop": true}, "7": {"name": "Winter trio", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "64", "beats": 8}, {"preset_id": "65", "beats": 8}, {"preset_id": "66", "beats": 8}], "step_duration_ms": 3000, "simulated_bpm": 80, "sequence_transition": 500, "loop": true}, "8": {"name": "Fast rainbow", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "1", "beats": 1}, {"preset_id": "3", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 180, "sequence_transition": 500, "loop": true}, "9": {"name": "Off then on", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "2", "beats": 2}, {"preset_id": "1", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": false}, "10": {"name": "Twinkle + flame", "profile_id": "1", "group_ids": ["5"], "lanes": [[{"preset_id": "41", "beats": 6}]], "lanes_group_ids": [["5"]], "advance_mode": "beats", "steps": [{"preset_id": "41", "beats": 6}], "step_duration_ms": 3000, "simulated_bpm": 110, "sequence_transition": 500, "loop": true}, "11": {"name": "radiate chase", "profile_id": "1", "group_ids": [], "lanes": [[{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}]], "lanes_group_ids": [[]], "advance_mode": "beats", "steps": [{"preset_id": "42", "beats": 12}, {"preset_id": "5", "beats": 4}], "step_duration_ms": 3000, "simulated_bpm": 120, "sequence_transition": 500, "loop": true}}
|
||||
Submodule led-driver updated: 55a97ac51c...85490a3bd0
2
led-tool
2
led-tool
Submodule led-tool updated: ccc215acbd...1edcb8b1f7
@@ -8,7 +8,7 @@ from models.device import (
|
||||
)
|
||||
from models.group import Group
|
||||
from models.transport import get_current_sender
|
||||
from settings import Settings
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
from models.wifi_ws_clients import (
|
||||
normalize_tcp_peer_ip,
|
||||
@@ -77,7 +77,7 @@ def _brightness_save_message_json(b_val: int) -> str:
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
_group_registry = Group()
|
||||
_pi_settings = Settings()
|
||||
_pi_settings = get_settings()
|
||||
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
|
||||
@@ -5,14 +5,14 @@ from models.group import Group
|
||||
from models.device import Device
|
||||
from models.transport import get_current_sender
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
||||
from settings import Settings
|
||||
from settings import get_settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
groups = Group()
|
||||
devices = Device()
|
||||
_pi_settings = Settings()
|
||||
_pi_settings = get_settings()
|
||||
|
||||
|
||||
def _group_doc_visible_for_profile(doc, profile_id):
|
||||
|
||||
@@ -30,6 +30,7 @@ def get_current_profile_id(session=None):
|
||||
@with_session
|
||||
async def list_sequences(request, session):
|
||||
"""List sequences for the current profile."""
|
||||
sequences.load()
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
if not current_profile_id:
|
||||
return json.dumps({}), 200, {"Content-Type": "application/json"}
|
||||
@@ -97,6 +98,7 @@ async def import_sequence(request, session):
|
||||
@with_session
|
||||
async def get_sequence(request, session, id):
|
||||
"""Get a specific sequence by ID (current profile only)."""
|
||||
sequences.load()
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
seq = sequences.read(id)
|
||||
if (
|
||||
@@ -203,15 +205,46 @@ async def delete_sequence(request, session, id):
|
||||
return json.dumps({"error": "Sequence not found"}), 404
|
||||
|
||||
|
||||
@controller.post("/sync-phase")
|
||||
@with_session
|
||||
async def sync_sequence_beat_phase(request, session):
|
||||
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
|
||||
_ = session
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
mode = data.get("mode") or data.get("align") or "step"
|
||||
try:
|
||||
from util.sequence_playback import sync_beat_phase
|
||||
|
||||
if not await sync_beat_phase(str(mode)):
|
||||
return (
|
||||
json.dumps({"error": "No sequence is playing"}),
|
||||
409,
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
from util.audio_detector import anchor_shared_bar_phase
|
||||
|
||||
anchor_shared_bar_phase()
|
||||
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/stop")
|
||||
@with_session
|
||||
async def stop_sequence_playback(request, session):
|
||||
"""Stop server-driven zone sequence playback."""
|
||||
_ = request
|
||||
try:
|
||||
from util.sequence_playback import stop
|
||||
from util.sequence_playback import stop_playback
|
||||
|
||||
stop()
|
||||
await stop_playback(clear_devices=True)
|
||||
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||
@@ -251,8 +284,12 @@ async def play_sequence(request, session, id):
|
||||
try:
|
||||
from util.sequence_playback import start
|
||||
|
||||
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
|
||||
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||
play_opts = data if isinstance(data, dict) else None
|
||||
await start(zone_id, str(id), str(current_profile_id), play_opts)
|
||||
from util.sequence_playback import pending_play_status
|
||||
|
||||
body = {"ok": True, **pending_play_status()}
|
||||
return json.dumps(body), 200, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
except RuntimeError as e:
|
||||
|
||||
@@ -4,10 +4,10 @@ import json
|
||||
from microdot import Microdot, send_file
|
||||
|
||||
from models import wifi_ws_clients
|
||||
from settings import Settings
|
||||
from settings import get_settings
|
||||
|
||||
controller = Microdot()
|
||||
settings = Settings()
|
||||
settings = get_settings()
|
||||
|
||||
@controller.get('')
|
||||
async def get_settings(request):
|
||||
@@ -75,7 +75,21 @@ def _validate_global_brightness(value):
|
||||
return v
|
||||
|
||||
|
||||
@controller.put('/settings')
|
||||
def _validate_sequence_switch_wait(value):
|
||||
s = str(value).strip().lower()
|
||||
if s not in ("beat", "downbeat"):
|
||||
raise ValueError("sequence_switch_wait must be beat or downbeat")
|
||||
return s
|
||||
|
||||
|
||||
def _validate_audio_beat_phase_ms(value):
|
||||
v = int(value)
|
||||
if v < 0 or v > 500:
|
||||
raise ValueError("audio_beat_phase_ms must be between 0 and 500")
|
||||
return v
|
||||
|
||||
|
||||
@controller.put('')
|
||||
async def update_settings(request):
|
||||
"""Update general settings."""
|
||||
try:
|
||||
@@ -87,6 +101,10 @@ async def update_settings(request):
|
||||
elif key == 'global_brightness' and value is not None:
|
||||
settings[key] = _validate_global_brightness(value)
|
||||
global_brightness_changed = True
|
||||
elif key == 'sequence_switch_wait' and value is not None:
|
||||
settings[key] = _validate_sequence_switch_wait(value)
|
||||
elif key == 'audio_beat_phase_ms' and value is not None:
|
||||
settings[key] = _validate_audio_beat_phase_ms(value)
|
||||
else:
|
||||
settings[key] = value
|
||||
settings.save()
|
||||
|
||||
@@ -145,6 +145,7 @@ async def zone_content_fragment(request, session, id):
|
||||
@controller.get("")
|
||||
@with_session
|
||||
async def list_zones(request, session):
|
||||
zones.load()
|
||||
profile_id = get_current_profile_id(session)
|
||||
current_zone_id = get_current_zone_id(request, session)
|
||||
zone_order = get_profile_zone_order(profile_id) if profile_id else []
|
||||
@@ -213,6 +214,7 @@ async def set_current_zone(request, id):
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_zone(request, id):
|
||||
zones.load()
|
||||
z = zones.read(id)
|
||||
if z:
|
||||
return json.dumps(z), 200, {"Content-Type": "application/json"}
|
||||
|
||||
110
src/main.py
110
src/main.py
@@ -10,7 +10,7 @@ import traceback
|
||||
from microdot import Microdot, send_file
|
||||
from microdot.websocket import with_websocket
|
||||
from microdot.session import Session
|
||||
from settings import Settings
|
||||
from settings import get_settings
|
||||
|
||||
import controllers.preset as preset
|
||||
import controllers.profile as profile
|
||||
@@ -159,8 +159,12 @@ async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
|
||||
sock.setblocking(False)
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
while not udp_holder.get("closing"):
|
||||
slept = 0.0
|
||||
while slept < interval and not udp_holder.get("closing"):
|
||||
chunk = min(1.0, interval - slept)
|
||||
await asyncio.sleep(chunk)
|
||||
slept += chunk
|
||||
if udp_holder.get("closing"):
|
||||
break
|
||||
try:
|
||||
@@ -244,7 +248,7 @@ async def _send_bridge_wifi_channel(settings, sender):
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
settings = Settings()
|
||||
settings = get_settings()
|
||||
print(settings)
|
||||
print("Starting")
|
||||
|
||||
@@ -377,7 +381,12 @@ async def main(port=80):
|
||||
audio_detector.start(device=device)
|
||||
from util.audio_run_persist import write_audio_run_state
|
||||
|
||||
write_audio_run_state(enabled=True, device=device)
|
||||
write_audio_run_state(
|
||||
enabled=True,
|
||||
device=device,
|
||||
device_override=str(payload.get("device_override") or ""),
|
||||
device_select=str(payload.get("device_select") or ""),
|
||||
)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}, 500
|
||||
@@ -391,6 +400,24 @@ async def main(port=80):
|
||||
write_audio_run_state(enabled=False)
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/reset', methods=['POST'])
|
||||
async def audio_reset(request):
|
||||
"""Clear beat/BPM tracking state without stopping the detector."""
|
||||
_ = request
|
||||
ok = audio_detector.reset_tracking()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/anchor-bar', methods=['POST'])
|
||||
async def audio_anchor_bar(request):
|
||||
"""Mark the current moment as bar beat 1 (downbeat)."""
|
||||
_ = request
|
||||
ok = audio_detector.anchor_bar_phase()
|
||||
if not ok:
|
||||
return {"ok": False, "error": "Audio detector is not running"}, 409
|
||||
return {"ok": True, "status": audio_detector.status()}
|
||||
|
||||
@app.route('/api/audio/status')
|
||||
async def audio_status(request):
|
||||
_ = request
|
||||
@@ -426,6 +453,14 @@ async def main(port=80):
|
||||
if bs > 0:
|
||||
beat_readout = str(bs)
|
||||
st["beat_readout"] = beat_readout
|
||||
from util.audio_run_persist import read_audio_run_state
|
||||
|
||||
st["beat_phase_ms"] = int(settings.get("audio_beat_phase_ms") or 0)
|
||||
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
|
||||
if seq_wait not in ("beat", "downbeat"):
|
||||
seq_wait = "beat"
|
||||
st["sequence_switch_wait"] = seq_wait
|
||||
st["audio_run"] = read_audio_run_state()
|
||||
return {"status": st}
|
||||
|
||||
# Static file route
|
||||
@@ -480,16 +515,30 @@ async def main(port=80):
|
||||
await _send_bridge_wifi_channel(settings, sender)
|
||||
_prime_wifi_outbound_driver_connections()
|
||||
|
||||
udp_holder = {"closing": False}
|
||||
udp_holder = {"closing": False, "shutting_down": False}
|
||||
loop = asyncio.get_running_loop()
|
||||
server_tasks: list[asyncio.Task] = []
|
||||
|
||||
def _graceful_shutdown(*_args):
|
||||
if udp_holder.get("shutting_down"):
|
||||
raise SystemExit(0)
|
||||
udp_holder["shutting_down"] = True
|
||||
print("[server] shutting down...")
|
||||
udp_holder["closing"] = True
|
||||
try:
|
||||
audio_detector.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.stop()
|
||||
for attr in ("_pending_beat_task", "_sim_beat_task"):
|
||||
t = getattr(seq_pb, attr, None)
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
u = udp_holder.get("sock")
|
||||
if u is not None:
|
||||
try:
|
||||
@@ -498,7 +547,13 @@ async def main(port=80):
|
||||
pass
|
||||
tcp_client_registry.cancel_all_driver_tasks()
|
||||
if getattr(app, "server", None) is not None:
|
||||
app.shutdown()
|
||||
try:
|
||||
app.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
for t in server_tasks:
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
|
||||
shutdown_handlers_registered = False
|
||||
try:
|
||||
@@ -511,11 +566,21 @@ async def main(port=80):
|
||||
|
||||
# Await HTTP + UDP discovery; bind failures (e.g. port 80 in use) surface here.
|
||||
try:
|
||||
await asyncio.gather(
|
||||
app.start_server(host="0.0.0.0", port=port),
|
||||
_run_udp_discovery_server(udp_holder),
|
||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||
)
|
||||
server_tasks[:] = [
|
||||
asyncio.create_task(
|
||||
app.start_server(host="0.0.0.0", port=port), name="http"
|
||||
),
|
||||
asyncio.create_task(
|
||||
_run_udp_discovery_server(udp_holder), name="udp"
|
||||
),
|
||||
asyncio.create_task(
|
||||
_periodic_wifi_driver_hello_loop(settings, udp_holder),
|
||||
name="hello",
|
||||
),
|
||||
]
|
||||
await asyncio.gather(*server_tasks)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except OSError as e:
|
||||
if e.errno == errno.EADDRINUSE:
|
||||
print(
|
||||
@@ -540,6 +605,21 @@ async def main(port=80):
|
||||
app.server = None
|
||||
except Exception:
|
||||
pass
|
||||
udp_holder["closing"] = True
|
||||
for t in list(server_tasks):
|
||||
if not t.done():
|
||||
t.cancel()
|
||||
if server_tasks:
|
||||
await asyncio.gather(*server_tasks, return_exceptions=True)
|
||||
pending = [
|
||||
t
|
||||
for t in asyncio.all_tasks(loop)
|
||||
if t is not asyncio.current_task() and not t.done()
|
||||
]
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
if pending:
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
if shutdown_handlers_registered:
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
@@ -549,5 +629,9 @@ async def main(port=80):
|
||||
|
||||
if __name__ == "__main__":
|
||||
import os
|
||||
|
||||
port = int(os.environ.get("PORT", 80))
|
||||
asyncio.run(main(port=port))
|
||||
try:
|
||||
asyncio.run(main(port=port))
|
||||
except KeyboardInterrupt:
|
||||
print("[server] interrupted")
|
||||
|
||||
@@ -22,7 +22,7 @@ class Zone(Model):
|
||||
"""Preset layout row (stored in ``db/zone.json``); legacy storage was ``tab.json`` / Tab.
|
||||
|
||||
Optional ``content_kind`` on a row: ``\"presets\"`` (preset tiles only) or ``\"sequences\"``
|
||||
(sequence tiles only). Omitted or unknown => both (legacy behaviour).
|
||||
(sequence tiles only). Legacy rows without ``content_kind`` are inferred on load.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -43,6 +43,12 @@ class Zone(Model):
|
||||
if "preset_group_ids" not in doc or not isinstance(doc.get("preset_group_ids"), dict):
|
||||
doc["preset_group_ids"] = {}
|
||||
changed = True
|
||||
if "sequence_ids" not in doc or not isinstance(doc.get("sequence_ids"), list):
|
||||
doc["sequence_ids"] = []
|
||||
changed = True
|
||||
if not self._normalized_content_kind(doc):
|
||||
doc["content_kind"] = self._infer_content_kind(doc)
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
@@ -53,6 +59,41 @@ class Zone(Model):
|
||||
kind = doc.get("content_kind")
|
||||
return kind if kind in ("presets", "sequences") else None
|
||||
|
||||
@staticmethod
|
||||
def _preset_ids_in_doc(doc):
|
||||
if not isinstance(doc, dict):
|
||||
return []
|
||||
flat = doc.get("presets_flat")
|
||||
if isinstance(flat, list):
|
||||
return [str(x) for x in flat if x is not None and str(x).strip()]
|
||||
presets = doc.get("presets")
|
||||
if not isinstance(presets, list) or not presets:
|
||||
return []
|
||||
if isinstance(presets[0], str):
|
||||
return [str(x) for x in presets if x is not None and str(x).strip()]
|
||||
if isinstance(presets[0], list):
|
||||
out = []
|
||||
for row in presets:
|
||||
if isinstance(row, list):
|
||||
out.extend(str(x) for x in row if x is not None and str(x).strip())
|
||||
return out
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _infer_content_kind(cls, doc):
|
||||
kind = cls._normalized_content_kind(doc)
|
||||
if kind:
|
||||
return kind
|
||||
seq_ids = [
|
||||
str(x).strip()
|
||||
for x in (doc.get("sequence_ids") or [])
|
||||
if x is not None and str(x).strip()
|
||||
]
|
||||
preset_ids = cls._preset_ids_in_doc(doc)
|
||||
if seq_ids and not preset_ids:
|
||||
return "sequences"
|
||||
return "presets"
|
||||
|
||||
def _enforce_content_kind_invariants(self, doc):
|
||||
"""Presets-only zones hold no sequences; sequences-only hold no preset tiles."""
|
||||
kind = self._normalized_content_kind(doc)
|
||||
@@ -95,7 +136,8 @@ class Zone(Model):
|
||||
return False
|
||||
patch = data if isinstance(data, dict) else {}
|
||||
self[id_str].update(patch)
|
||||
self._enforce_content_kind_invariants(self[id_str])
|
||||
if "content_kind" in patch:
|
||||
self._enforce_content_kind_invariants(self[id_str])
|
||||
self.save()
|
||||
return True
|
||||
|
||||
|
||||
@@ -12,11 +12,15 @@ def _settings_path():
|
||||
return "settings.json"
|
||||
|
||||
|
||||
_settings_singleton: "Settings | None" = None
|
||||
|
||||
|
||||
class Settings(dict):
|
||||
SETTINGS_FILE = None # Set in __init__ from _settings_path()
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, *, quiet: bool = False):
|
||||
super().__init__()
|
||||
self._quiet = quiet
|
||||
if Settings.SETTINGS_FILE is None:
|
||||
Settings.SETTINGS_FILE = _settings_path()
|
||||
self.load() # Load settings from file during initialization
|
||||
@@ -79,13 +83,22 @@ class Settings(dict):
|
||||
# Zone UI global brightness (0–255); shared across browsers/devices.
|
||||
if 'global_brightness' not in self:
|
||||
self['global_brightness'] = 255
|
||||
# Sequence tile start: wait for beat or downbeat (server-owned).
|
||||
if 'sequence_switch_wait' not in self:
|
||||
self['sequence_switch_wait'] = 'beat'
|
||||
elif str(self.get('sequence_switch_wait', '')).strip().lower() == 'phrase':
|
||||
self['sequence_switch_wait'] = 'beat'
|
||||
# Beat flash alignment delay (ms); applied by all UI clients polling audio status.
|
||||
if 'audio_beat_phase_ms' not in self:
|
||||
self['audio_beat_phase_ms'] = 0
|
||||
|
||||
def save(self):
|
||||
try:
|
||||
j = json.dumps(self)
|
||||
with open(self.SETTINGS_FILE, 'w') as file:
|
||||
file.write(j)
|
||||
print("Settings saved successfully.")
|
||||
if not getattr(self, "_quiet", False):
|
||||
print("Settings saved successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
@@ -96,9 +109,11 @@ class Settings(dict):
|
||||
loaded_settings = json.load(file)
|
||||
self.update(loaded_settings)
|
||||
loaded_from_file = True
|
||||
print("Settings loaded successfully.")
|
||||
if not getattr(self, "_quiet", False):
|
||||
print("Settings loaded successfully.")
|
||||
except Exception as e:
|
||||
print(f"Error loading settings")
|
||||
if not getattr(self, "_quiet", False):
|
||||
print(f"Error loading settings: {e}")
|
||||
self.clear()
|
||||
finally:
|
||||
# Ensure defaults are set even if file exists but is missing keys
|
||||
@@ -106,3 +121,18 @@ class Settings(dict):
|
||||
# Only save if file didn't exist or was invalid
|
||||
if not loaded_from_file:
|
||||
self.save()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Process-wide settings instance (avoid re-reading settings.json on every request)."""
|
||||
global _settings_singleton
|
||||
if _settings_singleton is None:
|
||||
_settings_singleton = Settings()
|
||||
return _settings_singleton
|
||||
|
||||
|
||||
def reload_settings() -> Settings:
|
||||
"""Re-read settings.json (e.g. after external file edit)."""
|
||||
global _settings_singleton
|
||||
_settings_singleton = Settings(quiet=True)
|
||||
return _settings_singleton
|
||||
|
||||
@@ -14,49 +14,6 @@
|
||||
/** @type {Set<ReturnType<typeof setTimeout>>} */
|
||||
const pendingBeatPhaseTimers = new Set();
|
||||
|
||||
const STORAGE_KEY = "led-controller-audio-restore";
|
||||
const PHASE_MS_KEY = "led-controller-audio-beat-phase-ms";
|
||||
const STORAGE_VERSION = 1;
|
||||
|
||||
function readRestorePrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const o = JSON.parse(raw);
|
||||
if (!o || o.v !== STORAGE_VERSION || !o.restore) return null;
|
||||
return {
|
||||
override: typeof o.override === "string" ? o.override : "",
|
||||
select: typeof o.select === "string" ? o.select : "",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeRestorePrefs(override, select) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
v: STORAGE_VERSION,
|
||||
restore: true,
|
||||
override: override || "",
|
||||
select: select || "",
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn("audio restore prefs save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRestorePrefs() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.warn("audio restore prefs clear failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
@@ -155,21 +112,91 @@
|
||||
node.textContent = `${label}${conf}`;
|
||||
}
|
||||
|
||||
/** @param {Record<string, unknown>} status */
|
||||
function updateBarPhaseDisplay(status) {
|
||||
const readout = String((status && status.bar_phase_readout) || "").trim();
|
||||
const phaseConf = Number((status && status.phase_confidence) || 0);
|
||||
const downbeat = !!(status && status.is_downbeat);
|
||||
let text = readout || "--";
|
||||
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
|
||||
text = `${text} (${Math.round(phaseConf * 100)}%)`;
|
||||
}
|
||||
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
|
||||
const node = el(id);
|
||||
if (!node) continue;
|
||||
node.textContent = status && status.running ? text : "";
|
||||
node.classList.toggle("is-downbeat", downbeat && !!readout);
|
||||
}
|
||||
}
|
||||
|
||||
function setTopBpmVisible(on) {
|
||||
const top = el("audio-top-indicator");
|
||||
if (!top) return;
|
||||
top.classList.toggle("audio-running", !!on);
|
||||
}
|
||||
|
||||
function setNavResetVisible(on) {
|
||||
for (const id of ["audio-nav-reset-btn", "audio-nav-reset-mobile"]) {
|
||||
const node = el(id);
|
||||
if (node) node.hidden = !on;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetAudioTracking() {
|
||||
try {
|
||||
const res = await fetch("/api/audio/reset", {
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
console.warn("audio reset failed", data.error || res.status);
|
||||
return;
|
||||
}
|
||||
await pollStatus();
|
||||
} catch (e) {
|
||||
console.warn("audio reset failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSequenceSyncControls(zoneSeqActive) {
|
||||
const topSync = el("audio-top-beat-sync");
|
||||
if (topSync) topSync.disabled = !zoneSeqActive;
|
||||
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 syncSequenceBeatPhase(mode) {
|
||||
const res = await fetch("/sequences/sync-phase", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ mode: mode || "step" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || `Sync failed (${res.status})`);
|
||||
}
|
||||
await pollStatus();
|
||||
}
|
||||
|
||||
function isTypingTarget(target) {
|
||||
if (!target || typeof target !== "object") return false;
|
||||
const tag = String(target.tagName || "").toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
|
||||
}
|
||||
|
||||
function flashBeat() {
|
||||
const node = el("audio-beat-flash");
|
||||
if (!node) return;
|
||||
node.classList.add("active");
|
||||
setTimeout(() => node.classList.remove("active"), 80);
|
||||
const syncBtn = el("audio-top-beat-sync");
|
||||
const top = el("audio-top-indicator");
|
||||
if (top && top.classList.contains("audio-running")) {
|
||||
top.classList.add("flash");
|
||||
setTimeout(() => top.classList.remove("flash"), 90);
|
||||
if (syncBtn && top && top.classList.contains("audio-running")) {
|
||||
syncBtn.classList.add("flash");
|
||||
setTimeout(() => syncBtn.classList.remove("flash"), 90);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,17 +211,17 @@
|
||||
const n = parseInt(String(inp.value).trim(), 10);
|
||||
if (Number.isFinite(n)) return Math.min(500, Math.max(0, n));
|
||||
}
|
||||
try {
|
||||
const v = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||
return Number.isFinite(v) ? Math.min(500, Math.max(0, v)) : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function persistBeatPhaseMs() {
|
||||
async function persistBeatPhaseMs() {
|
||||
const ms = getBeatPhaseDelayMs();
|
||||
try {
|
||||
localStorage.setItem(PHASE_MS_KEY, String(getBeatPhaseDelayMs()));
|
||||
await fetch("/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ audio_beat_phase_ms: ms }),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("beat phase ms save failed", e);
|
||||
}
|
||||
@@ -224,6 +251,7 @@
|
||||
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
|
||||
async function stopAudioOnly() {
|
||||
setTopBpmVisible(false);
|
||||
setNavResetVisible(false);
|
||||
clearBeatPhaseTimers();
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
@@ -241,10 +269,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** User-initiated stop: also forget auto-restart on next page load. */
|
||||
/** User-initiated stop (run intent cleared on server). */
|
||||
async function stopAudio() {
|
||||
await stopAudioOnly();
|
||||
clearRestorePrefs();
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
@@ -260,22 +287,30 @@
|
||||
updateBeatReadoutDisplays({});
|
||||
updateBpmDisplay(null);
|
||||
setTopBpmVisible(!!status.running);
|
||||
setNavResetVisible(!!status.running);
|
||||
if (!status.running && pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
setTopBpmVisible(!!status.running);
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
setTopBpmVisible(!!status.running || zoneSeqActive);
|
||||
setNavResetVisible(!!status.running);
|
||||
updateSequenceSyncControls(zoneSeqActive);
|
||||
updateBpmDisplay(status.bpm);
|
||||
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
|
||||
updateBarPhaseDisplay(status);
|
||||
applyServerAudioUiFields(status);
|
||||
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
|
||||
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
|
||||
}
|
||||
/*
|
||||
* `status.beat_seq` is cumulative since Audio Start — used only for flash / sticky idle
|
||||
* after sequence ends. Preset and sequence loop counts come from `manual_beat_stride` /
|
||||
* `sequence` on each poll.
|
||||
*/
|
||||
const beatSeq = Number(status.beat_seq || 0);
|
||||
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
|
||||
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
|
||||
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
|
||||
prevZoneSequencePlaybackActive = zoneSeqActive;
|
||||
@@ -320,7 +355,11 @@
|
||||
const selected = el("audio-device-select")?.value || "";
|
||||
const rawDevice = override !== "" ? override : selected;
|
||||
const numeric = rawDevice !== "" && /^-?\d+$/.test(rawDevice) ? Number(rawDevice) : rawDevice;
|
||||
const body = { device: rawDevice === "" ? null : numeric };
|
||||
const body = {
|
||||
device: rawDevice === "" ? null : numeric,
|
||||
device_override: override,
|
||||
device_select: selected,
|
||||
};
|
||||
const res = await fetch("/api/audio/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -330,7 +369,6 @@
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "Failed to start audio detector");
|
||||
}
|
||||
writeRestorePrefs(override, selected);
|
||||
updateBpmDisplay(null);
|
||||
updateHitTypeDisplay("unknown", NaN);
|
||||
pollTimer = setInterval(pollStatus, 250);
|
||||
@@ -378,6 +416,7 @@
|
||||
const closeBtn = el("audio-close-btn");
|
||||
const startBtn = el("audio-start-btn");
|
||||
const stopBtn = el("audio-stop-btn");
|
||||
const navResetBtn = el("audio-nav-reset-btn");
|
||||
const refreshBtn = el("audio-refresh-btn");
|
||||
if (!modal || !openBtn) return;
|
||||
|
||||
@@ -410,6 +449,9 @@
|
||||
await stopAudio();
|
||||
});
|
||||
}
|
||||
if (navResetBtn) {
|
||||
navResetBtn.addEventListener("click", () => resetAudioTracking());
|
||||
}
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
@@ -422,17 +464,36 @@
|
||||
|
||||
const phaseInp = el("audio-beat-phase-ms");
|
||||
if (phaseInp) {
|
||||
try {
|
||||
const stored = parseInt(localStorage.getItem(PHASE_MS_KEY) || "0", 10);
|
||||
if (Number.isFinite(stored)) {
|
||||
phaseInp.value = String(Math.min(500, Math.max(0, stored)));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
phaseInp.addEventListener("change", () => persistBeatPhaseMs());
|
||||
phaseInp.addEventListener("input", () => persistBeatPhaseMs());
|
||||
phaseInp.addEventListener("change", () => {
|
||||
void persistBeatPhaseMs();
|
||||
});
|
||||
phaseInp.addEventListener("input", () => {
|
||||
void persistBeatPhaseMs();
|
||||
});
|
||||
}
|
||||
|
||||
const bindSync = (node, mode) => {
|
||||
if (!node) return;
|
||||
node.addEventListener("click", async () => {
|
||||
try {
|
||||
await syncSequenceBeatPhase(mode);
|
||||
} catch (e) {
|
||||
console.warn("sequence beat sync failed", e);
|
||||
}
|
||||
});
|
||||
};
|
||||
bindSync(el("audio-top-beat-sync"), "step");
|
||||
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() {
|
||||
@@ -451,27 +512,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply browser-stored device fields only (GET /devices list); does not start detection.
|
||||
* Beat detector run/stop is server-owned (`db/audio_run.json` + explicit Start/Stop in UI).
|
||||
*/
|
||||
async function applySavedAudioDeviceFormOnly() {
|
||||
const prefs = readRestorePrefs();
|
||||
if (!prefs) return;
|
||||
const ov = el("audio-device-override");
|
||||
const sel = el("audio-device-select");
|
||||
if (ov) ov.value = prefs.override || "";
|
||||
/** Apply server-owned audio UI fields from status (device form, beat phase delay). */
|
||||
function applyServerAudioUiFields(status) {
|
||||
if (!status || typeof status !== "object") return;
|
||||
const run = status.audio_run;
|
||||
if (run && typeof run === "object") {
|
||||
const ov = el("audio-device-override");
|
||||
const sel = el("audio-device-select");
|
||||
if (ov && run.device_override != null) ov.value = String(run.device_override);
|
||||
if (sel && run.device_select) sel.value = String(run.device_select);
|
||||
}
|
||||
const phaseInp = el("audio-beat-phase-ms");
|
||||
if (
|
||||
phaseInp &&
|
||||
status.beat_phase_ms != null &&
|
||||
document.activeElement !== phaseInp
|
||||
) {
|
||||
const ms = parseInt(String(status.beat_phase_ms), 10);
|
||||
if (Number.isFinite(ms)) {
|
||||
phaseInp.value = String(Math.min(500, Math.max(0, ms)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerAudioUiFields() {
|
||||
try {
|
||||
await refreshDevices();
|
||||
} catch (e) {
|
||||
console.warn("audio device list refresh failed", e);
|
||||
}
|
||||
if (sel && prefs.select) sel.value = prefs.select;
|
||||
try {
|
||||
const res = await fetch("/api/audio/status", { cache: "no-store" });
|
||||
const data = await res.json();
|
||||
applyServerAudioUiFields(data?.status || {});
|
||||
} catch (e) {
|
||||
console.warn("audio status load failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from sequences.js when server playback starts/stops without audio polling. */
|
||||
window.ledControllerSequencePlaybackChanged = (active) => {
|
||||
updateSequenceSyncControls(!!active);
|
||||
if (active) {
|
||||
setTopBpmVisible(true);
|
||||
return;
|
||||
}
|
||||
if (!pollTimer) {
|
||||
setTopBpmVisible(false);
|
||||
updateSequenceSyncControls(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
bind();
|
||||
await loadServerAudioUiFields();
|
||||
await resumePollingIfDetectorRunning();
|
||||
await applySavedAudioDeviceFormOnly();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -131,7 +131,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
const response = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
117
src/static/numpad.js
Normal file
117
src/static/numpad.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Bluetooth / USB HID numpad shortcuts (browser focus required).
|
||||
*
|
||||
* Numpad1–9,0 → zone 1–10 (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));
|
||||
});
|
||||
})();
|
||||
@@ -264,6 +264,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn');
|
||||
const presetModeInput = document.getElementById('preset-mode-input');
|
||||
const presetModeGroup = document.getElementById('preset-mode-group');
|
||||
const presetReverseInput = document.getElementById('preset-reverse-input');
|
||||
const presetReverseGroup = document.getElementById('preset-reverse-group');
|
||||
|
||||
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) {
|
||||
return;
|
||||
@@ -350,6 +352,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null;
|
||||
|
||||
const patternSupportsReverse = (patternName) => {
|
||||
const cfg = resolvePatternConfig(patternName);
|
||||
return !!(cfg && cfg.supports_reverse);
|
||||
};
|
||||
|
||||
const setPresetReverseFieldVisible = (show) => {
|
||||
if (!presetReverseGroup) {
|
||||
return;
|
||||
}
|
||||
presetReverseGroup.hidden = !show;
|
||||
presetReverseGroup.style.display = show ? '' : 'none';
|
||||
if (!show && presetReverseInput) {
|
||||
presetReverseInput.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPresetModeFieldVisible = (show) => {
|
||||
if (!presetModeGroup) {
|
||||
return;
|
||||
@@ -773,6 +791,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (presetReverseInput) {
|
||||
const n5raw = preset.n5;
|
||||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10);
|
||||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||||
}
|
||||
|
||||
// Set n values, checking both n keys and descriptive names
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
@@ -828,6 +852,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (presetManualModeInput) {
|
||||
presetManualModeInput.checked = false;
|
||||
}
|
||||
if (presetReverseInput) {
|
||||
presetReverseInput.checked = false;
|
||||
}
|
||||
setPresetReverseFieldVisible(false);
|
||||
if (presetManualBeatNInput) {
|
||||
presetManualBeatNInput.value = '1';
|
||||
}
|
||||
@@ -872,7 +900,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabData = await tabRes.json();
|
||||
const allowed =
|
||||
typeof window.zoneAllowsPresets === 'function'
|
||||
? window.zoneAllowsPresets(tabData)
|
||||
? window.zoneAllowsPresets(tabData, currentEditTabId)
|
||||
: true;
|
||||
presetRemoveFromTabButton.hidden = !allowed;
|
||||
} catch (e) {
|
||||
@@ -951,13 +979,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const modeEntries = patternSupportsModes(payload.pattern)
|
||||
? getPatternModeOptions(payload.pattern)
|
||||
: null;
|
||||
const reverseField = patternSupportsReverse(payload.pattern);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const nKey = `n${i}`;
|
||||
if (modeEntries && nKey === 'n6') {
|
||||
continue;
|
||||
}
|
||||
if (reverseField && nKey === 'n5') {
|
||||
continue;
|
||||
}
|
||||
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
|
||||
}
|
||||
if (reverseField) {
|
||||
payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0;
|
||||
}
|
||||
if (modeEntries && presetModeInput) {
|
||||
payload.mode = parseInt(presetModeInput.value, 10) || 0;
|
||||
}
|
||||
@@ -1065,9 +1100,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
|
||||
const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null;
|
||||
const reverseField = patternSupportsReverse(patternName);
|
||||
if (modeEntries) {
|
||||
visibleNKeys.delete('n6');
|
||||
}
|
||||
if (reverseField) {
|
||||
visibleNKeys.delete('n5');
|
||||
}
|
||||
setPresetReverseFieldVisible(reverseField);
|
||||
if (reverseField && presetReverseInput) {
|
||||
const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0;
|
||||
const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10);
|
||||
presetReverseInput.checked = Number.isFinite(n5) && n5 > 0;
|
||||
}
|
||||
if (presetModeInput) {
|
||||
if (modeEntries) {
|
||||
setPresetModeFieldVisible(true);
|
||||
@@ -1355,7 +1400,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const zoneDoc = await zoneCheck.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(zoneDoc)
|
||||
!window.zoneAllowsPresets(zoneDoc, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
@@ -1495,7 +1540,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.');
|
||||
return;
|
||||
@@ -2214,7 +2259,7 @@ const savePresetGrid = async (zoneId, presetGrid) => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
throw new Error('This zone is for sequences only.');
|
||||
}
|
||||
@@ -2312,9 +2357,11 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
const tabData = await tabResponse.json();
|
||||
const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {};
|
||||
const ck =
|
||||
typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: null;
|
||||
typeof window.effectiveZoneContentKind === 'function'
|
||||
? window.effectiveZoneContentKind(tabData)
|
||||
: typeof window.normalizeZoneContentKind === 'function'
|
||||
? window.normalizeZoneContentKind(tabData)
|
||||
: 'presets';
|
||||
|
||||
// Get presets - support both 2D grid and flat array (for backward compatibility)
|
||||
let presetGrid = tabData.presets;
|
||||
@@ -2454,7 +2501,8 @@ const renderTabPresets = async (zoneId, options = {}) => {
|
||||
|
||||
if (
|
||||
typeof window.appendZoneSequenceTiles === 'function' &&
|
||||
(typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData))
|
||||
(typeof window.zoneAllowsSequences !== 'function' ||
|
||||
window.zoneAllowsSequences(tabData, zoneId))
|
||||
) {
|
||||
await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList);
|
||||
}
|
||||
@@ -2698,7 +2746,7 @@ const removePresetFromTab = async (zoneId, presetId) => {
|
||||
const tabData = await tabResponse.json();
|
||||
if (
|
||||
typeof window.zoneAllowsPresets === 'function' &&
|
||||
!window.zoneAllowsPresets(tabData)
|
||||
!window.zoneAllowsPresets(tabData, zoneId)
|
||||
) {
|
||||
alert('This zone is for sequences only.');
|
||||
return;
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
|
||||
// Debug: in the browser console run setSequenceDebug(true) — toggling logs 1 (on) or 0 (off).
|
||||
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
|
||||
|
||||
const SEQ_DEBUG_STORAGE_KEY = 'led-controller-sequence-debug';
|
||||
/** @type {'beat'|'downbeat'} */
|
||||
let sequenceSwitchWaitFor = 'beat';
|
||||
|
||||
let sequenceDebugEnabled = false;
|
||||
let sequenceSwitchSaveInFlight = false;
|
||||
|
||||
async function loadSequenceSwitchWaitForFromServer() {
|
||||
try {
|
||||
const res = await fetch('/settings', {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const raw = data && data.sequence_switch_wait;
|
||||
if (raw === 'downbeat' || raw === 'beat') {
|
||||
sequenceSwitchWaitFor = raw;
|
||||
} else if (raw === 'phrase') {
|
||||
sequenceSwitchWaitFor = 'beat';
|
||||
}
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSequenceSwitchWaitFor() {
|
||||
sequenceSwitchSaveInFlight = true;
|
||||
try {
|
||||
const res = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ sequence_switch_wait: sequenceSwitchWaitFor }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn('[sequence] could not save switch wait to server', res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[sequence] could not save switch wait to server', e);
|
||||
} finally {
|
||||
sequenceSwitchSaveInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getSequenceSwitchWaitFor() {
|
||||
return sequenceSwitchWaitFor === 'downbeat' ? 'downbeat' : 'beat';
|
||||
}
|
||||
|
||||
async function setSequenceSwitchWaitFor(waitFor) {
|
||||
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
|
||||
updateSequenceSwitchToggleUI();
|
||||
await persistSequenceSwitchWaitFor();
|
||||
}
|
||||
|
||||
function updateSequenceSwitchToggleUI() {
|
||||
const mode = getSequenceSwitchWaitFor();
|
||||
const ariaLabels = {
|
||||
beat: 'Switch sequence on beat',
|
||||
downbeat: 'Switch sequence on downbeat',
|
||||
};
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
|
||||
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
|
||||
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
|
||||
});
|
||||
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
|
||||
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
|
||||
});
|
||||
}
|
||||
|
||||
async function initSequenceSwitchToggle() {
|
||||
await loadSequenceSwitchWaitForFromServer();
|
||||
updateSequenceSwitchToggleUI();
|
||||
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
|
||||
function applySequenceSwitchWaitFromServer(raw) {
|
||||
if (sequenceSwitchSaveInFlight) return;
|
||||
let mode = 'beat';
|
||||
if (raw === 'downbeat') mode = 'downbeat';
|
||||
else if (raw !== 'beat' && raw !== 'phrase') return;
|
||||
if (mode === getSequenceSwitchWaitFor()) return;
|
||||
sequenceSwitchWaitFor = mode;
|
||||
updateSequenceSwitchToggleUI();
|
||||
}
|
||||
|
||||
function seqDebugEnabled() {
|
||||
try {
|
||||
return localStorage.getItem(SEQ_DEBUG_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return sequenceDebugEnabled;
|
||||
}
|
||||
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
@@ -1120,15 +1204,11 @@ async function loadSequencesModalList() {
|
||||
});
|
||||
}
|
||||
|
||||
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
|
||||
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
|
||||
/** @param {boolean} on */
|
||||
window.setSequenceDebug = function setSequenceDebug(on) {
|
||||
try {
|
||||
if (on) localStorage.setItem(SEQ_DEBUG_STORAGE_KEY, '1');
|
||||
else localStorage.removeItem(SEQ_DEBUG_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.warn('[sequence] could not persist debug flag', e);
|
||||
}
|
||||
sequenceDebugEnabled = !!on;
|
||||
console.log(seqDebugEnabled() ? 1 : 0);
|
||||
};
|
||||
window.appendZoneSequenceTiles = appendZoneSequenceTiles;
|
||||
@@ -1137,6 +1217,7 @@ window.addSequenceToTab = addSequenceToTab;
|
||||
window.removeSequenceFromTab = removeSequenceFromTab;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
void initSequenceSwitchToggle();
|
||||
const btn = document.getElementById('sequences-btn');
|
||||
const modal = document.getElementById('sequences-modal');
|
||||
const closeBtn = document.getElementById('sequences-close-btn');
|
||||
|
||||
@@ -106,7 +106,7 @@ header h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Second header row: BPM, brightness, desktop buttons / mobile menu */
|
||||
/* Top header row: BPM, brightness, desktop buttons, mobile menu (above zone tabs) */
|
||||
.header-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -199,20 +199,43 @@ header h1 {
|
||||
|
||||
.audio-top-indicator {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.15rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
min-width: 9rem;
|
||||
}
|
||||
|
||||
.audio-top-indicator-main {
|
||||
.audio-top-indicator.audio-running {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.audio-top-indicator .audio-top-beat-sync {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
min-height: 2.25rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.audio-top-beat-sync:not(:disabled):hover {
|
||||
border-color: #6a6a6a;
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
.audio-top-indicator-extra {
|
||||
@@ -226,10 +249,6 @@ header h1 {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.audio-top-indicator.audio-running {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.audio-top-indicator-label {
|
||||
font-size: 0.72rem;
|
||||
color: #bdbdbd;
|
||||
@@ -245,16 +264,46 @@ header h1 {
|
||||
}
|
||||
|
||||
.audio-top-beat-readout {
|
||||
font-size: 0.62rem;
|
||||
font-size: 0.75rem;
|
||||
color: #b0bec5;
|
||||
line-height: 1.25;
|
||||
max-width: 12rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
min-width: 2rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-beat-readout:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-top-beat-readout:not(:empty)::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase {
|
||||
font-size: 0.7rem;
|
||||
color: #90a4ae;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase:not(:empty)::before {
|
||||
content: "·";
|
||||
margin-right: 0.35rem;
|
||||
color: #757575;
|
||||
}
|
||||
|
||||
.audio-top-bar-phase.is-downbeat {
|
||||
color: #ffab91;
|
||||
}
|
||||
|
||||
.audio-top-indicator-subvalue {
|
||||
@@ -264,16 +313,15 @@ header h1 {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash {
|
||||
.audio-top-beat-sync.flash {
|
||||
background-color: #ff5252;
|
||||
border-color: #ff8a80;
|
||||
}
|
||||
|
||||
.audio-top-indicator.flash .audio-top-indicator-value,
|
||||
.audio-top-indicator.flash .audio-top-indicator-label,
|
||||
.audio-top-indicator.flash .audio-top-indicator-subvalue,
|
||||
.audio-top-indicator.flash .audio-top-indicator-extra,
|
||||
.audio-top-indicator.flash .audio-top-beat-readout {
|
||||
.audio-top-beat-sync.flash .audio-top-indicator-value,
|
||||
.audio-top-beat-sync.flash .audio-top-indicator-label,
|
||||
.audio-top-beat-sync.flash .audio-top-beat-readout,
|
||||
.audio-top-beat-sync.flash .audio-top-beat-readout::before {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -333,7 +381,7 @@ body.preset-ui-run .edit-mode-only {
|
||||
|
||||
.zones-container {
|
||||
background-color: transparent;
|
||||
padding: 0.35rem 0 0;
|
||||
padding: 0;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -863,12 +911,41 @@ body.preset-ui-run .edit-mode-only {
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
#audio-modal .audio-settings-section {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
#audio-modal .audio-settings-section .audio-modal-beat-readout {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
min-height: 2.25rem;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.35;
|
||||
text-align: left;
|
||||
text-align: center;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 6px;
|
||||
background-color: #252525;
|
||||
padding: 0.35rem 0.65rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: #b0bec5;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.audio-modal-beat-readout:not(:disabled):hover {
|
||||
border-color: #6a6a6a;
|
||||
background-color: #333;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.audio-hit-type-readout {
|
||||
@@ -1003,13 +1080,98 @@ body.preset-ui-run .edit-mode-only {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit {
|
||||
background-color: #4a3f8f;
|
||||
border: 1px solid #7b6fd6;
|
||||
.nav-slide-toggle-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-mode-toggle--edit:hover {
|
||||
.nav-slide-toggle-side-label {
|
||||
font-size: 0.82rem;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-wrap:not(.nav-slide-toggle-wrap--downbeat) .nav-slide-toggle-side-label--beat,
|
||||
.nav-slide-toggle-wrap--downbeat .nav-slide-toggle-side-label--downbeat {
|
||||
color: #e8e8e8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch {
|
||||
position: relative;
|
||||
width: 2.75rem;
|
||||
height: 1.4rem;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
appearance: none;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 999px;
|
||||
background-color: #2a2a2a;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch:hover {
|
||||
border-color: #666;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch:focus-visible {
|
||||
outline: 2px solid #7b6fd6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-track {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 2px;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background-color: #bdbdbd;
|
||||
transform: translateY(-50%);
|
||||
transition: left 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat {
|
||||
background-color: #4a3f8f;
|
||||
border-color: #7b6fd6;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat:hover {
|
||||
background-color: #5a4f9f;
|
||||
border-color: #8b7fe6;
|
||||
}
|
||||
|
||||
.nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||
left: calc(100% - 1rem - 2px);
|
||||
transform: translateY(-50%);
|
||||
background-color: #e8e4ff;
|
||||
}
|
||||
|
||||
.main-menu-dropdown .nav-slide-toggle-wrap--mobile {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-bottom: 1px solid #333;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Preset select buttons inside the zone grid */
|
||||
@@ -1261,13 +1423,43 @@ body.preset-ui-run .edit-mode-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Beat/downbeat toggle lives in the mobile menu only */
|
||||
#seq-switch-toggle-wrap {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.main-menu-dropdown {
|
||||
max-width: min(16rem, calc(100vw - 1rem));
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-side-label {
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle {
|
||||
width: 3.6rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch .nav-slide-toggle-thumb {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
#seq-switch-toggle-wrap-mobile .nav-slide-toggle-switch.seq-switch-toggle--downbeat .nav-slide-toggle-thumb {
|
||||
left: calc(100% - 0.9rem - 2px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.header-menu-mobile {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.header-end {
|
||||
@@ -1277,10 +1469,15 @@ body.preset-ui-run .edit-mode-only {
|
||||
|
||||
.header-end .audio-top-indicator {
|
||||
min-width: 5rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-end .audio-top-beat-sync {
|
||||
padding: 0.2rem 0.4rem;
|
||||
min-height: 2rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
|
||||
@@ -515,22 +515,38 @@ function editModalContentKindSelected() {
|
||||
return radio && radio.value === 'sequences' ? 'sequences' : 'presets';
|
||||
}
|
||||
|
||||
function activeZoneContentKind(zoneDoc) {
|
||||
function activeZoneContentKind(zoneDoc, zoneId) {
|
||||
const modal = document.getElementById('edit-zone-modal');
|
||||
if (modal && modal.classList.contains('active')) {
|
||||
const editingId = document.getElementById('edit-zone-id')?.value;
|
||||
if (
|
||||
modal &&
|
||||
modal.classList.contains('active') &&
|
||||
zoneId != null &&
|
||||
zoneId !== '' &&
|
||||
String(editingId) === String(zoneId)
|
||||
) {
|
||||
return editModalContentKindSelected();
|
||||
}
|
||||
return effectiveZoneContentKind(zoneDoc);
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsPresets(zoneDoc) {
|
||||
return activeZoneContentKind(zoneDoc) === 'presets';
|
||||
/** True when the zone row has an explicit presets vs sequences type (not legacy inferred). */
|
||||
function zoneHasExplicitContentKind(zoneDoc) {
|
||||
return normalizeZoneContentKind(zoneDoc) !== null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc) {
|
||||
return activeZoneContentKind(zoneDoc) === 'sequences';
|
||||
function zoneAllowsPresets(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'presets';
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
function zoneAllowsSequences(zoneDoc, zoneId) {
|
||||
void zoneId;
|
||||
if (!zoneHasExplicitContentKind(zoneDoc)) return true;
|
||||
return effectiveZoneContentKind(zoneDoc) === 'sequences';
|
||||
}
|
||||
|
||||
function applyZoneContentKindEditModal(kind) {
|
||||
@@ -1028,7 +1044,7 @@ async function refreshEditTabPresetsUi(zoneId) {
|
||||
return;
|
||||
}
|
||||
const tabData = await tabRes.json();
|
||||
if (!zoneAllowsPresets(tabData)) {
|
||||
if (!zoneAllowsPresets(tabData, zoneId)) {
|
||||
currentEl.innerHTML =
|
||||
'<span class="muted-text">This zone is for sequences only. Presets are hidden.</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">—</span>';
|
||||
@@ -1187,16 +1203,32 @@ async function updateZone(zoneId, name, groupRows, contentKind) {
|
||||
: [];
|
||||
const ck =
|
||||
contentKind === 'sequences' || contentKind === 'presets' ? contentKind : 'presets';
|
||||
let existing = {};
|
||||
try {
|
||||
const cur = await fetch(`/zones/${encodeURIComponent(zoneId)}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (cur.ok) {
|
||||
const j = await cur.json();
|
||||
if (j && typeof j === 'object') existing = j;
|
||||
}
|
||||
} catch (_) {
|
||||
/* use empty existing */
|
||||
}
|
||||
const response = await fetch(`/zones/${zoneId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...existing,
|
||||
name: name,
|
||||
names: [],
|
||||
group_ids: gids,
|
||||
preset_group_ids: {},
|
||||
preset_group_ids:
|
||||
existing.preset_group_ids && typeof existing.preset_group_ids === 'object'
|
||||
? existing.preset_group_ids
|
||||
: {},
|
||||
content_kind: ck,
|
||||
})
|
||||
});
|
||||
|
||||
@@ -9,18 +9,21 @@
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<header>
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-end">
|
||||
<div id="audio-top-indicator" class="audio-top-indicator" title="Live audio BPM">
|
||||
<div class="audio-top-indicator-main">
|
||||
<div class="nav-slide-toggle-wrap seq-switch-toggle-wrap edit-mode-only" id="seq-switch-toggle-wrap">
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle" aria-pressed="false" aria-label="Switch sequence on beat" title="When starting a sequence: wait for beat or downbeat">
|
||||
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||
</button>
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||
</div>
|
||||
<div id="audio-top-indicator" class="audio-top-indicator">
|
||||
<button type="button" id="audio-top-beat-sync" class="audio-top-beat-sync" disabled title="Sync step to music (S)">
|
||||
<span class="audio-top-indicator-label">BPM</span>
|
||||
<span id="audio-top-bpm-value" class="audio-top-indicator-value">--</span>
|
||||
<span id="audio-top-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
|
||||
</div>
|
||||
<span id="audio-top-bar-phase" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-brightness-control">
|
||||
@@ -38,12 +41,20 @@
|
||||
<button class="btn btn-secondary edit-mode-only" id="send-profile-presets-btn">Send Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="led-tool-btn">LED Tool</button>
|
||||
<button class="btn btn-secondary" id="audio-btn">Audio</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-nav-reset-btn" hidden title="Clear stuck BPM / beat tracking">Reset detector</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
<button type="button" class="btn btn-secondary ui-mode-toggle" id="ui-mode-toggle" aria-pressed="false" title="Switch preset strip mode — label is the mode you will switch to">Edit mode</button>
|
||||
</div>
|
||||
<div class="header-menu-mobile">
|
||||
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<div class="nav-slide-toggle-wrap nav-slide-toggle-wrap--mobile seq-switch-toggle-wrap" id="seq-switch-toggle-wrap-mobile">
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--beat">Beat</span>
|
||||
<button type="button" role="switch" class="nav-slide-toggle-switch seq-switch-toggle" id="seq-switch-toggle-mobile" aria-pressed="false" aria-label="Switch sequence on beat" title="Beat or downbeat">
|
||||
<span class="nav-slide-toggle-track" aria-hidden="true"><span class="nav-slide-toggle-thumb"></span></span>
|
||||
</button>
|
||||
<span class="nav-slide-toggle-side-label nav-slide-toggle-side-label--downbeat">Downbeat</span>
|
||||
</div>
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<div class="menu-brightness-control">
|
||||
<label for="menu-brightness-slider">Brightness</label>
|
||||
@@ -60,10 +71,16 @@
|
||||
<button type="button" class="edit-mode-only" data-target="send-profile-presets-btn">Send Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="led-tool-btn">LED Tool</button>
|
||||
<button type="button" data-target="audio-btn">Audio</button>
|
||||
<button type="button" id="audio-nav-reset-mobile" data-target="audio-nav-reset-btn" hidden>Reset detector</button>
|
||||
<button type="button" data-target="help-btn">Help</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="zones-container">
|
||||
<div id="zones-list">
|
||||
Loading zones...
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
@@ -348,6 +365,10 @@
|
||||
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
|
||||
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
|
||||
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;">—</p>
|
||||
<label style="display:block;margin-top:0.65rem;">
|
||||
<input type="checkbox" id="sequence-editor-loop" checked>
|
||||
Loop sequence (restart from the first step after the last)
|
||||
</label>
|
||||
</div>
|
||||
<div id="sequence-editor-lanes"></div>
|
||||
<div class="modal-actions preset-editor-modal-actions">
|
||||
@@ -405,6 +426,12 @@
|
||||
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preset-editor-field" id="preset-reverse-group" hidden>
|
||||
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
|
||||
<input type="checkbox" id="preset-reverse-input">
|
||||
Reverse direction (strip installed upside down)
|
||||
</label>
|
||||
</div>
|
||||
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
|
||||
<label for="preset-mode-input" id="preset-mode-label">Mode</label>
|
||||
<select id="preset-mode-input" class="preset-mode-input"></select>
|
||||
@@ -619,22 +646,45 @@
|
||||
<label>Current BPM</label>
|
||||
<div class="audio-bpm-row">
|
||||
<div id="audio-bpm-value" class="audio-bpm-readout">--</div>
|
||||
<div id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Detected hit type</label>
|
||||
<div id="audio-hit-type-value" class="audio-hit-type-readout">unknown</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Bar phase</label>
|
||||
<div class="audio-bpm-row">
|
||||
<div id="audio-bar-phase-value" class="audio-bpm-readout" title="Beat in bar (kick hints downbeat)">--</div>
|
||||
</div>
|
||||
<small class="muted-text">Bar uses kick-heavy hits (default 4/4). Tap <strong>Sync</strong> on a downbeat to lock bar phase.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Flash on beat</label>
|
||||
<div id="audio-beat-flash" class="audio-beat-flash" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||
<small class="muted-text">Delays beat flashes and sequenced beats so they line up with what you hear (saved in this browser).</small>
|
||||
|
||||
<div class="settings-section audio-settings-section">
|
||||
<h3>Audio settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="audio-beat-phase-ms">Beat phase shift (ms)</label>
|
||||
<input type="number" id="audio-beat-phase-ms" min="0" max="500" step="5" value="0" style="width:6rem;">
|
||||
<small class="muted-text">Delays beat flashes so they line up with what you hear (saved on the controller).</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Beat sync</label>
|
||||
<button type="button" id="audio-modal-beat-readout" class="audio-modal-beat-readout muted-text" disabled title="Sync step to music (S)" aria-live="polite"></button>
|
||||
<small class="muted-text">While a sequence is playing, tap the BPM/beat button in the header on a downbeat to align the step counter. Shortcut: <kbd>S</kbd>.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Sequence alignment</label>
|
||||
<div class="profiles-actions" style="flex-wrap: wrap;">
|
||||
<button type="button" class="btn btn-secondary" id="audio-sync-pass-btn">Restart pass</button>
|
||||
</div>
|
||||
<small class="muted-text"><strong>Restart pass</strong> jumps to step 1 of the sequence (<kbd>Shift+S</kbd>). Use <strong>Reset detector</strong> in the header (while audio is running) to clear stuck BPM/beat tracking without stopping audio.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
|
||||
<button type="button" class="btn btn-secondary" id="audio-stop-btn">Stop</button>
|
||||
@@ -805,5 +855,6 @@
|
||||
<script src="/static/sequences.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/audio.js"></script>
|
||||
<script src="/static/numpad.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -261,7 +261,7 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/settings/settings', {
|
||||
const response = await fetch('/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ wifi_channel: wifiChannel }),
|
||||
|
||||
@@ -4,6 +4,12 @@ import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
_HOLDOVER_BPM_MIN = 30.0
|
||||
_HOLDOVER_BPM_MAX = 300.0
|
||||
_HOLDOVER_MAX_S = 300.0
|
||||
|
||||
|
||||
class AudioBeatDetector:
|
||||
@@ -13,6 +19,11 @@ class AudioBeatDetector:
|
||||
self._stream = None
|
||||
self._running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._runtime = None
|
||||
self._pending_reset = False
|
||||
self._holdover_thread: threading.Thread | None = None
|
||||
self._holdover_stop = threading.Event()
|
||||
self._holdover_active = False
|
||||
self._status = {
|
||||
"running": False,
|
||||
"bpm": None,
|
||||
@@ -20,6 +31,11 @@ class AudioBeatDetector:
|
||||
"beat_seq": 0,
|
||||
"beat_type": "unknown",
|
||||
"beat_type_confidence": 0.0,
|
||||
"bar_beat": 1,
|
||||
"beats_per_bar": 4,
|
||||
"is_downbeat": False,
|
||||
"phase_confidence": 0.0,
|
||||
"bar_phase_readout": "1/4",
|
||||
"error": None,
|
||||
"device": None,
|
||||
}
|
||||
@@ -100,6 +116,11 @@ class AudioBeatDetector:
|
||||
"beat_seq": 0,
|
||||
"beat_type": "unknown",
|
||||
"beat_type_confidence": 0.0,
|
||||
"bar_beat": 1,
|
||||
"beats_per_bar": 4,
|
||||
"is_downbeat": False,
|
||||
"phase_confidence": 0.0,
|
||||
"bar_phase_readout": "1/4",
|
||||
"error": None,
|
||||
"device": device,
|
||||
}
|
||||
@@ -111,6 +132,7 @@ class AudioBeatDetector:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_bpm_holdover()
|
||||
with self._lock:
|
||||
self._stop_event.set()
|
||||
t = self._thread
|
||||
@@ -139,11 +161,159 @@ class AudioBeatDetector:
|
||||
self._running = False
|
||||
self._thread = None
|
||||
self._stream = None
|
||||
self._pending_reset = False
|
||||
self._status["running"] = False
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
return dict(self._status)
|
||||
st = dict(self._status)
|
||||
holdover = self._holdover_active
|
||||
last = st.get("last_beat_ts")
|
||||
if st.get("running") and last is not None and not holdover:
|
||||
try:
|
||||
if (time.time() - float(last)) > 4.0:
|
||||
st["bpm"] = None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return st
|
||||
|
||||
def _apply_tracking_reset_status(self) -> None:
|
||||
"""Refresh published status after a tracking reset (lock must be held)."""
|
||||
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||
self._status.update(
|
||||
{
|
||||
"running": True,
|
||||
"beat_type": "unknown",
|
||||
"beat_type_confidence": 0.0,
|
||||
"bar_beat": 1,
|
||||
"is_downbeat": True,
|
||||
"phase_confidence": 0.0,
|
||||
"bar_phase_readout": f"1/{bpb}",
|
||||
}
|
||||
)
|
||||
|
||||
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
|
||||
try:
|
||||
v = float(bpm)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
|
||||
return None
|
||||
return v
|
||||
|
||||
def _holdover_interval_s(self, bpm: float) -> float:
|
||||
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
|
||||
|
||||
def _stop_bpm_holdover(self) -> None:
|
||||
with self._lock:
|
||||
self._holdover_active = False
|
||||
self._holdover_stop.set()
|
||||
t = self._holdover_thread
|
||||
if t and t.is_alive() and t is not threading.current_thread():
|
||||
t.join(timeout=2.0)
|
||||
with self._lock:
|
||||
if self._holdover_thread is t:
|
||||
self._holdover_thread = None
|
||||
|
||||
def _advance_holdover_bar_phase_locked(self) -> dict:
|
||||
"""Advance bar phase for one synthetic beat (lock must be held)."""
|
||||
bpb = max(1, int(self._status.get("beats_per_bar") or 4))
|
||||
prev = int(self._status.get("bar_beat") or 1)
|
||||
bar_beat = (prev % bpb) + 1
|
||||
is_downbeat = bar_beat == 1
|
||||
bar_readout = f"{bar_beat}/{bpb}"
|
||||
self._status["bar_beat"] = bar_beat
|
||||
self._status["is_downbeat"] = is_downbeat
|
||||
self._status["bar_phase_readout"] = bar_readout
|
||||
return {
|
||||
"bar_beat": bar_beat,
|
||||
"beats_per_bar": bpb,
|
||||
"is_downbeat": is_downbeat,
|
||||
"bar_phase_readout": bar_readout,
|
||||
}
|
||||
|
||||
def _emit_holdover_beat(self, bpm: float) -> None:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
if not self._running or not self._holdover_active:
|
||||
return
|
||||
self._advance_holdover_bar_phase_locked()
|
||||
self._status["last_beat_ts"] = now
|
||||
self._status["bpm"] = float(bpm)
|
||||
self._status["beat_type"] = "holdover"
|
||||
self._status["beat_type_confidence"] = 0.0
|
||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
seq_pb.push_thread_beat()
|
||||
except Exception as e:
|
||||
print(f"[audio] holdover beat queue: {e}")
|
||||
|
||||
def _holdover_loop(self, bpm: float, started_at: float) -> None:
|
||||
interval = self._holdover_interval_s(bpm)
|
||||
while not self._holdover_stop.is_set():
|
||||
with self._lock:
|
||||
if not self._running or not self._holdover_active:
|
||||
return
|
||||
if (time.time() - started_at) > _HOLDOVER_MAX_S:
|
||||
self._holdover_active = False
|
||||
return
|
||||
last = self._status.get("last_beat_ts")
|
||||
if last is not None:
|
||||
try:
|
||||
delay = max(0.02, float(last) + interval - time.time())
|
||||
except (TypeError, ValueError):
|
||||
delay = interval
|
||||
else:
|
||||
delay = interval
|
||||
if self._holdover_stop.wait(delay):
|
||||
return
|
||||
self._emit_holdover_beat(bpm)
|
||||
|
||||
def _start_bpm_holdover(self, bpm: float) -> None:
|
||||
bpm_v = self._clamp_holdover_bpm(bpm)
|
||||
if bpm_v is None:
|
||||
return
|
||||
self._stop_bpm_holdover()
|
||||
self._holdover_stop.clear()
|
||||
started_at = time.time()
|
||||
with self._lock:
|
||||
self._holdover_active = True
|
||||
self._holdover_thread = threading.Thread(
|
||||
target=self._holdover_loop,
|
||||
args=(bpm_v, started_at),
|
||||
name="audio-bpm-holdover",
|
||||
daemon=True,
|
||||
)
|
||||
t = self._holdover_thread
|
||||
t.start()
|
||||
|
||||
def _process_pending_reset(self, runtime) -> None:
|
||||
"""Run ``reset_state`` on the audio thread (safe for aubio tempo)."""
|
||||
with self._lock:
|
||||
if not self._pending_reset:
|
||||
return
|
||||
self._pending_reset = False
|
||||
try:
|
||||
runtime.reset_state()
|
||||
with self._lock:
|
||||
self._apply_tracking_reset_status()
|
||||
except Exception as e:
|
||||
print(f"[audio] pending reset: {e}")
|
||||
|
||||
def reset_tracking(self) -> bool:
|
||||
"""Clear detector tempo history without stopping the input stream."""
|
||||
holdover_bpm = None
|
||||
with self._lock:
|
||||
if not self._running or self._runtime is None:
|
||||
return False
|
||||
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
|
||||
self._pending_reset = True
|
||||
self._apply_tracking_reset_status()
|
||||
if holdover_bpm is not None:
|
||||
self._start_bpm_holdover(holdover_bpm)
|
||||
return True
|
||||
|
||||
def _set_error(self, msg):
|
||||
print(f"[audio] {msg}")
|
||||
@@ -152,7 +322,28 @@ class AudioBeatDetector:
|
||||
self._status["running"] = False
|
||||
self._running = False
|
||||
|
||||
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0):
|
||||
def anchor_bar_phase(self) -> bool:
|
||||
"""Mark the current moment as bar beat 1 (downbeat), e.g. after manual sync."""
|
||||
with self._lock:
|
||||
rt = self._runtime
|
||||
if rt is None:
|
||||
return False
|
||||
try:
|
||||
rt.anchor_bar_phase(time.time())
|
||||
with self._lock:
|
||||
self._status["bar_beat"] = 1
|
||||
self._status["is_downbeat"] = True
|
||||
self._status["bar_phase_readout"] = f"1/{int(self._status.get('beats_per_bar') or 4)}"
|
||||
self._status["phase_confidence"] = max(
|
||||
float(self._status.get("phase_confidence") or 0.0), 0.85
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[audio] anchor_bar_phase: {e}")
|
||||
return False
|
||||
|
||||
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
|
||||
self._stop_bpm_holdover()
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
self._status["last_beat_ts"] = now
|
||||
@@ -160,6 +351,16 @@ class AudioBeatDetector:
|
||||
self._status["beat_type"] = beat_type
|
||||
self._status["beat_type_confidence"] = float(beat_type_confidence or 0.0)
|
||||
self._status["beat_seq"] = int(self._status.get("beat_seq", 0)) + 1
|
||||
if phase_fields.get("bar_beat") is not None:
|
||||
self._status["bar_beat"] = int(phase_fields["bar_beat"])
|
||||
if phase_fields.get("beats_per_bar") is not None:
|
||||
self._status["beats_per_bar"] = int(phase_fields["beats_per_bar"])
|
||||
if phase_fields.get("is_downbeat") is not None:
|
||||
self._status["is_downbeat"] = bool(phase_fields["is_downbeat"])
|
||||
if phase_fields.get("phase_confidence") is not None:
|
||||
self._status["phase_confidence"] = float(phase_fields["phase_confidence"])
|
||||
if phase_fields.get("bar_phase_readout"):
|
||||
self._status["bar_phase_readout"] = str(phase_fields["bar_phase_readout"])
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
|
||||
@@ -210,15 +411,17 @@ class AudioBeatDetector:
|
||||
flux_weight=0.3,
|
||||
threshold_multiplier=1.35,
|
||||
ema_alpha=0.08,
|
||||
min_ioi_ms=85.0,
|
||||
min_ioi_ms=100.0,
|
||||
bpm_window=8,
|
||||
post_url="",
|
||||
aubio_method="default",
|
||||
aubio_threshold=0.12,
|
||||
silence_gate_db=-58.0,
|
||||
aubio_threshold=0.14,
|
||||
beats_per_bar=4,
|
||||
)
|
||||
runtime = beat_mod.BeatDetectRuntime(args)
|
||||
runtime.setup(sample_rate=sample_rate)
|
||||
with self._lock:
|
||||
self._runtime = runtime
|
||||
hop_size = runtime.frame_size
|
||||
|
||||
audio_q = queue.Queue(maxsize=64)
|
||||
@@ -243,10 +446,12 @@ class AudioBeatDetector:
|
||||
stream.start()
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
self._process_pending_reset(runtime)
|
||||
try:
|
||||
frame = audio_q.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
self._process_pending_reset(runtime)
|
||||
if frame.shape[0] != hop_size:
|
||||
if frame.shape[0] > hop_size:
|
||||
frame = frame[:hop_size]
|
||||
@@ -260,6 +465,11 @@ class AudioBeatDetector:
|
||||
bpm,
|
||||
beat_type=event.get("beat_type", "unknown"),
|
||||
beat_type_confidence=event.get("beat_type_confidence", 0.0),
|
||||
bar_beat=event.get("bar_beat"),
|
||||
beats_per_bar=event.get("beats_per_bar"),
|
||||
is_downbeat=event.get("is_downbeat"),
|
||||
phase_confidence=event.get("phase_confidence"),
|
||||
bar_phase_readout=event.get("bar_phase_readout"),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
@@ -280,6 +490,7 @@ class AudioBeatDetector:
|
||||
with self._lock:
|
||||
self._running = False
|
||||
self._status["running"] = False
|
||||
self._runtime = None
|
||||
|
||||
|
||||
# Set from ``main`` so sequence playback can tell real audio from simulated beats.
|
||||
@@ -299,3 +510,25 @@ def shared_beat_detector_running():
|
||||
return bool(d.status().get("running"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def shared_beat_status_snapshot() -> dict:
|
||||
"""Thread-safe copy of live detector status, or {} if audio is off."""
|
||||
d = _shared_beat_detector
|
||||
if d is None:
|
||||
return {}
|
||||
try:
|
||||
return dict(d.status())
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def anchor_shared_bar_phase() -> bool:
|
||||
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
|
||||
d = _shared_beat_detector
|
||||
if d is None:
|
||||
return False
|
||||
try:
|
||||
return bool(d.anchor_bar_phase())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -30,20 +30,64 @@ def read_audio_run_state() -> Dict[str, Any]:
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
return {"enabled": False, "device": None}
|
||||
if not isinstance(raw, dict):
|
||||
return {"enabled": False, "device": None}
|
||||
return {
|
||||
"enabled": False,
|
||||
"device": None,
|
||||
"device_override": "",
|
||||
"device_select": "",
|
||||
}
|
||||
enabled = bool(raw.get("enabled"))
|
||||
dev = raw.get("device", None)
|
||||
return {"enabled": enabled, "device": dev}
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"device": dev,
|
||||
"device_override": str(raw.get("device_override") or ""),
|
||||
"device_select": str(raw.get("device_select") or ""),
|
||||
}
|
||||
|
||||
|
||||
def write_audio_run_state(*, enabled: bool, device: Any = None) -> None:
|
||||
"""Write run intent. When ``enabled`` is false, keep ``device`` from the previous file for next start."""
|
||||
def write_audio_run_state(
|
||||
*,
|
||||
enabled: bool,
|
||||
device: Any = None,
|
||||
device_override: str | None = None,
|
||||
device_select: str | None = None,
|
||||
) -> None:
|
||||
"""Write run intent. When ``enabled`` is false, keep device fields from the previous file."""
|
||||
path = _db_path()
|
||||
prev = read_audio_run_state()
|
||||
if enabled:
|
||||
data = {"enabled": True, "device": device}
|
||||
data = {
|
||||
"enabled": True,
|
||||
"device": device,
|
||||
"device_override": (
|
||||
str(device_override)
|
||||
if device_override is not None
|
||||
else str(prev.get("device_override") or "")
|
||||
),
|
||||
"device_select": (
|
||||
str(device_select)
|
||||
if device_select is not None
|
||||
else str(prev.get("device_select") or "")
|
||||
),
|
||||
}
|
||||
if device_select is None and device is not None:
|
||||
data["device_select"] = str(device)
|
||||
else:
|
||||
data = {"enabled": False, "device": prev.get("device")}
|
||||
data = {
|
||||
"enabled": False,
|
||||
"device": prev.get("device"),
|
||||
"device_override": (
|
||||
str(device_override)
|
||||
if device_override is not None
|
||||
else str(prev.get("device_override") or "")
|
||||
),
|
||||
"device_select": (
|
||||
str(device_select)
|
||||
if device_select is not None
|
||||
else str(prev.get("device_select") or "")
|
||||
),
|
||||
}
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
|
||||
@@ -299,6 +299,10 @@ def _apply_manual_beat_route_standalone_overlay(
|
||||
return
|
||||
names = [str(n).strip() for n in device_names if str(n).strip()]
|
||||
with _route_lock:
|
||||
if _sequence_lane_covers_standalone_overlay(names, str(wire_preset_id).strip()):
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
return
|
||||
_lane_manual[-1] = {
|
||||
"device_names": names,
|
||||
"wire_preset_id": str(wire_preset_id).strip(),
|
||||
@@ -350,6 +354,11 @@ def set_sequence_manual_lane_route(
|
||||
"manual_beat_n": mn,
|
||||
"beat_counter": bc,
|
||||
}
|
||||
overlay = _lane_manual.get(-1)
|
||||
if overlay and _lane_route_targets_key(names, wid) == _lane_route_targets_key(
|
||||
overlay.get("device_names") or [], str(overlay.get("wire_preset_id") or "")
|
||||
):
|
||||
_lane_manual.pop(-1, None)
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
|
||||
@@ -362,6 +371,49 @@ def clear_sequence_manual_lane_route(lane_index: int) -> None:
|
||||
_sync_public_beat_route_from_lane_table()
|
||||
|
||||
|
||||
def _lane_route_targets_key(device_names: List[str], wire_preset_id: str) -> Tuple[Tuple[str, ...], str]:
|
||||
names = tuple(sorted({str(n).strip() for n in (device_names or []) if str(n).strip()}))
|
||||
return names, str(wire_preset_id or "").strip()
|
||||
|
||||
|
||||
def _sequence_lane_covers_standalone_overlay(device_names: List[str], wire_preset_id: str) -> bool:
|
||||
"""True when a sequence lane (0..n) already routes the same device(s) and wire preset."""
|
||||
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||
for lane_key, entry in _lane_manual.items():
|
||||
if not isinstance(lane_key, int) or lane_key < 0:
|
||||
continue
|
||||
other = _lane_route_targets_key(
|
||||
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||
)
|
||||
if other == key:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def mark_manual_select_sent_for_targets(
|
||||
device_names: List[str], wire_preset_id: str
|
||||
) -> None:
|
||||
"""A ``select`` was just sent for these targets; skip one duplicate on the next beat."""
|
||||
key = _lane_route_targets_key(device_names, wire_preset_id)
|
||||
with _route_lock:
|
||||
for entry in _lane_manual.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
other = _lane_route_targets_key(
|
||||
entry.get("device_names") or [], str(entry.get("wire_preset_id") or "")
|
||||
)
|
||||
if other == key:
|
||||
entry["suppress_next_notify"] = True
|
||||
|
||||
|
||||
def mark_sequence_manual_lane_select_sent(lane_index: int) -> None:
|
||||
"""A ``select`` was just sent for this lane; skip one duplicate on the next beat."""
|
||||
with _route_lock:
|
||||
e = _lane_manual.get(lane_index)
|
||||
if e is not None:
|
||||
e["suppress_next_notify"] = True
|
||||
|
||||
|
||||
def sync_beat_route_from_push_sequence(
|
||||
sequence: List[Any],
|
||||
target_macs: Optional[List[str]] = None,
|
||||
@@ -438,6 +490,7 @@ def sync_beat_route_from_push_sequence(
|
||||
)
|
||||
else:
|
||||
_apply_manual_beat_route(device_names, wire_preset_id, preset_body)
|
||||
mark_manual_select_sent_for_targets(device_names, wire_preset_id)
|
||||
return
|
||||
|
||||
wire_id, body = _single_manual_wire_preset(merged_presets)
|
||||
@@ -547,6 +600,7 @@ def notify_beat_detected() -> None:
|
||||
if not _lane_manual:
|
||||
return
|
||||
work = []
|
||||
seen_targets: Set[Tuple[Tuple[str, ...], str]] = set()
|
||||
for key in sorted(_lane_manual.keys()):
|
||||
e = _lane_manual[key]
|
||||
names = e.get("device_names") or []
|
||||
@@ -555,6 +609,8 @@ def notify_beat_detected() -> None:
|
||||
pattern = str(e.get("pattern") or "")
|
||||
if pattern and not _pattern_supports_manual(pattern):
|
||||
continue
|
||||
if e.pop("suppress_next_notify", False):
|
||||
continue
|
||||
try:
|
||||
n = int(e.get("manual_beat_n") or 1)
|
||||
except (TypeError, ValueError):
|
||||
@@ -564,7 +620,12 @@ def notify_beat_detected() -> None:
|
||||
c = int(e["beat_counter"])
|
||||
if (c - 1) % n != 0:
|
||||
continue
|
||||
work.append((list(names), str(e.get("wire_preset_id") or "2")))
|
||||
wire = str(e.get("wire_preset_id") or "2")
|
||||
target_key = _lane_route_targets_key(names, wire)
|
||||
if target_key in seen_targets:
|
||||
continue
|
||||
seen_targets.add(target_key)
|
||||
work.append((list(names), wire))
|
||||
if work:
|
||||
_preset_session_beats += 1
|
||||
if not work:
|
||||
|
||||
@@ -24,6 +24,16 @@ _sim_beat_token = 0
|
||||
_beat_run: Optional[Dict[str, Any]] = None
|
||||
_beat_run_lock = threading.Lock()
|
||||
|
||||
_pending_play: Optional[Dict[str, Any]] = None
|
||||
_pending_play_lock = threading.Lock()
|
||||
_pending_beat_task: Optional[asyncio.Task] = None
|
||||
_pending_beat_token = 0
|
||||
_last_thread_beat_phase: Dict[str, Any] = {
|
||||
"is_downbeat": True,
|
||||
"bar_beat": 1,
|
||||
}
|
||||
_sim_beat_counter = 0
|
||||
|
||||
|
||||
def _norm_mac(raw: Any) -> Optional[str]:
|
||||
from models.device import normalize_mac
|
||||
@@ -299,21 +309,6 @@ def _resolve_colors_with_palette_refs(
|
||||
return out
|
||||
|
||||
|
||||
def _ordered_unique_preset_ids_from_lanes(lanes: List[List[Dict[str, Any]]]) -> List[str]:
|
||||
seen: set = set()
|
||||
out: List[str] = []
|
||||
for lane in lanes:
|
||||
for step in lane:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
pid = str(step.get("preset_id") or "").strip()
|
||||
if not pid or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
out.append(pid)
|
||||
return out
|
||||
|
||||
|
||||
def _display_preset_for_step(
|
||||
preset_id: str,
|
||||
presets_map: Dict[str, Any],
|
||||
@@ -348,6 +343,129 @@ def _preset_inner_from_display_preset(display_preset: Dict[str, Any]) -> Dict[st
|
||||
return inner
|
||||
|
||||
|
||||
def _ordered_unique_preset_ids_in_lane(lane: List[Dict[str, Any]]) -> List[str]:
|
||||
seen: set = set()
|
||||
out: List[str] = []
|
||||
for step in lane:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
pid = str(step.get("preset_id") or "").strip()
|
||||
if not pid or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
out.append(pid)
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_lane_device_names(lane_index: int, ctx: Dict[str, Any]) -> List[str]:
|
||||
"""Device names for one lane (lane groups / whole zone), after lane partition."""
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
||||
zone_doc: Dict[str, Any] = ctx["zone_doc"]
|
||||
devices = ctx["devices"]
|
||||
groups = ctx["groups"]
|
||||
num_lanes = int(ctx["num_lanes"])
|
||||
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
|
||||
if not lane:
|
||||
return []
|
||||
gids = _group_ids_for_lane_step(sequence_doc, lane[0], lane_index, num_lanes)
|
||||
device_names = _resolve_step_device_names(
|
||||
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||
)
|
||||
return _split_device_names_for_lane(
|
||||
device_names,
|
||||
lane_index,
|
||||
num_lanes,
|
||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||
)
|
||||
|
||||
|
||||
def _build_lane_wire_presets_map(lane_index: int, ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""All preset wire bodies for one lane, keyed by preset id."""
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||
palette_colors: List[Any] = ctx["palette_colors"]
|
||||
lane = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
|
||||
inner_by_wire: Dict[str, Any] = {}
|
||||
for pid in _ordered_unique_preset_ids_in_lane(lane):
|
||||
disp = _display_preset_for_step(pid, presets_map, palette_colors)
|
||||
if not disp:
|
||||
continue
|
||||
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
|
||||
return inner_by_wire
|
||||
|
||||
|
||||
async def _prime_lane(lane_index: int, ctx: Dict[str, Any]) -> None:
|
||||
"""Upload all lane presets and select step 0 in one message (driver applies presets before select)."""
|
||||
from models.transport import get_current_sender
|
||||
from util.beat_driver_route import (
|
||||
clear_sequence_manual_lane_route,
|
||||
mark_sequence_manual_lane_select_sent,
|
||||
set_sequence_manual_lane_route,
|
||||
)
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||
palette_colors: List[Any] = ctx["palette_colors"]
|
||||
lane_steps = lanes[lane_index] if 0 <= lane_index < len(lanes) else []
|
||||
if not lane_steps:
|
||||
return
|
||||
|
||||
inner_by_wire = _build_lane_wire_presets_map(lane_index, ctx)
|
||||
if not inner_by_wire:
|
||||
return
|
||||
|
||||
step0 = lane_steps[0]
|
||||
preset_id = str(step0.get("preset_id") or "").strip()
|
||||
if not preset_id:
|
||||
return
|
||||
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
||||
if not display_preset:
|
||||
return
|
||||
|
||||
device_names = _resolve_lane_device_names(lane_index, ctx)
|
||||
macs = _device_names_to_macs(device_names, ctx["devices"])
|
||||
if not macs:
|
||||
return
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
devices_model = ctx["devices"]
|
||||
wire = str(preset_id)
|
||||
auto = _coerce_auto(display_preset)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
|
||||
delay_s = 0.05
|
||||
for mac in macs:
|
||||
body: Dict[str, Any] = {"v": "1", "presets": dict(inner_by_wire)}
|
||||
if sel:
|
||||
body["select"] = sel
|
||||
msg = json.dumps(body, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
|
||||
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
else:
|
||||
inner = _preset_inner_from_display_preset(display_preset)
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
|
||||
|
||||
async def _prime_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||
"""One-shot preset upload + first-step select per lane (to each lane's groups)."""
|
||||
for i in range(int(ctx["num_lanes"])):
|
||||
await _prime_lane(i, ctx)
|
||||
ctx["_presets_delivered"] = True
|
||||
ctx["_sequence_primed"] = True
|
||||
|
||||
|
||||
def _parse_zone_brightness_value(zone_doc: Any) -> int:
|
||||
"""Zone slider value stored on the zone row (0–255); default 255 if unset."""
|
||||
from util.brightness_combine import clamp255
|
||||
@@ -363,37 +481,33 @@ def _parse_zone_brightness_value(zone_doc: Any) -> int:
|
||||
return 255
|
||||
|
||||
|
||||
def _inner_wire_b_with_sequence_zone_brightness(
|
||||
inner: Dict[str, Any],
|
||||
zone_doc: Dict[str, Any],
|
||||
*,
|
||||
target_mac: Optional[str],
|
||||
settings_obj: Any,
|
||||
groups_model: Any,
|
||||
devices_model: Any,
|
||||
) -> Dict[str, Any]:
|
||||
"""Combine preset wire ``b`` with zone brightness (and global/group/device when ``target_mac`` is set)."""
|
||||
from util.brightness_combine import (
|
||||
clamp255,
|
||||
multiply_brightness_factors,
|
||||
effective_brightness_for_mac,
|
||||
)
|
||||
async def _deliver_zone_brightness_for_sequence(ctx: Dict[str, Any]) -> None:
|
||||
"""Apply zone/global/group/device brightness like the zone slider (not inside preset ``b``)."""
|
||||
from models.transport import get_current_sender
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
out = dict(inner)
|
||||
base = clamp255(out.get("b", 127))
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
zb = _parse_zone_brightness_value(zone_doc)
|
||||
if target_mac and settings_obj is not None and groups_model is not None and devices_model is not None:
|
||||
settings_obj = ctx.get("settings")
|
||||
groups_model = ctx.get("groups")
|
||||
devices_model = ctx.get("devices")
|
||||
for mac in macs:
|
||||
eff = effective_brightness_for_mac(
|
||||
settings_obj,
|
||||
groups_model,
|
||||
devices_model,
|
||||
target_mac,
|
||||
mac,
|
||||
zone_brightness=zb,
|
||||
)
|
||||
out["b"] = multiply_brightness_factors([base, eff])
|
||||
else:
|
||||
out["b"] = multiply_brightness_factors([base, zb])
|
||||
return out
|
||||
msg = json.dumps({"v": "1", "b": eff, "save": True}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=0.05)
|
||||
|
||||
|
||||
def _device_names_to_macs(device_names: List[str], devices: Any) -> List[str]:
|
||||
@@ -455,52 +569,21 @@ def _union_macs_for_sequence(ctx: Dict[str, Any]) -> List[str]:
|
||||
return list(z_macs)
|
||||
|
||||
|
||||
def _build_sequence_wire_presets_map(ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||
palette_colors: List[Any] = ctx["palette_colors"]
|
||||
inner_by_wire: Dict[str, Any] = {}
|
||||
for pid in _ordered_unique_preset_ids_from_lanes(lanes):
|
||||
disp = _display_preset_for_step(pid, presets_map, palette_colors)
|
||||
if not disp:
|
||||
continue
|
||||
inner_by_wire[str(pid)] = _preset_inner_from_display_preset(disp)
|
||||
return inner_by_wire
|
||||
|
||||
|
||||
async def _deliver_sequence_presets_bulk(ctx: Dict[str, Any]) -> None:
|
||||
"""Push all preset definitions used in the sequence once; step advances use select (auto) only."""
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
inner_by_wire = _build_sequence_wire_presets_map(ctx)
|
||||
ctx["_sequence_wire_presets"] = inner_by_wire
|
||||
if not inner_by_wire:
|
||||
return
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
raise RuntimeError("Transport not configured")
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
zone_doc = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
settings_obj = ctx.get("settings")
|
||||
groups_model = ctx.get("groups")
|
||||
devices_model = ctx.get("devices")
|
||||
delay_s = 0.05
|
||||
for mac in macs:
|
||||
adjusted: Dict[str, Any] = {}
|
||||
for wire_pid, inner in inner_by_wire.items():
|
||||
adjusted[wire_pid] = _inner_wire_b_with_sequence_zone_brightness(
|
||||
inner,
|
||||
zone_doc,
|
||||
target_mac=mac,
|
||||
settings_obj=settings_obj,
|
||||
groups_model=groups_model,
|
||||
devices_model=devices_model,
|
||||
)
|
||||
msg = json.dumps({"v": "1", "presets": adjusted}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], [mac], devices_model, delay_s=delay_s)
|
||||
def _coerce_loop(sequence_doc: Dict[str, Any]) -> bool:
|
||||
raw = sequence_doc.get("loop", sequence_doc.get("sequence_loop", True))
|
||||
if isinstance(raw, bool):
|
||||
return raw
|
||||
if raw is None:
|
||||
return True
|
||||
if isinstance(raw, int):
|
||||
return raw != 0
|
||||
if isinstance(raw, str):
|
||||
lo = raw.strip().lower()
|
||||
if lo in ("false", "0", "no", "off"):
|
||||
return False
|
||||
if lo in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _coerce_auto(preset: Dict[str, Any]) -> bool:
|
||||
@@ -533,119 +616,17 @@ def _load_palette_colors(profile_id: str) -> List[Any]:
|
||||
return Palette().read(str(pid)) or []
|
||||
|
||||
|
||||
async def _deliver_preset_for_devices(
|
||||
preset_id: str,
|
||||
preset_doc: Dict[str, Any],
|
||||
device_names: List[str],
|
||||
devices: Any,
|
||||
*,
|
||||
lane_index: Optional[int] = None,
|
||||
zone_doc: Optional[Dict[str, Any]] = None,
|
||||
settings_obj: Any = None,
|
||||
groups_model: Any = None,
|
||||
) -> None:
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||
from util.espnow_message import build_preset_dict
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
raise RuntimeError("Transport not configured")
|
||||
|
||||
macs: List[str] = []
|
||||
seen: set = set()
|
||||
for nm in device_names:
|
||||
key = str(nm).strip()
|
||||
if not key:
|
||||
continue
|
||||
m = None
|
||||
for did in devices.list():
|
||||
doc = devices.read(did) or {}
|
||||
if str(doc.get("name") or "").strip() == key:
|
||||
m = _norm_mac(did)
|
||||
break
|
||||
if not m and key.startswith("led-"):
|
||||
m = _norm_mac(key[4:])
|
||||
if m and m not in seen:
|
||||
seen.add(m)
|
||||
macs.append(m)
|
||||
if not macs:
|
||||
return
|
||||
|
||||
body = dict(preset_doc)
|
||||
auto = _coerce_auto(body)
|
||||
inner_base = build_preset_dict(body)
|
||||
mb = body.get("manual_beat_n", body.get("manualBeatN"))
|
||||
if mb is not None:
|
||||
try:
|
||||
n = int(mb)
|
||||
if 1 <= n <= 64:
|
||||
inner_base["manual_beat_n"] = n
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
wire = str(preset_id)
|
||||
zone_use = zone_doc if isinstance(zone_doc, dict) else {}
|
||||
|
||||
sel_append: Optional[Dict[str, Any]] = None
|
||||
if auto and device_names:
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if sel:
|
||||
sel_append = {"v": "1", "select": sel}
|
||||
|
||||
for mac in macs:
|
||||
inner = _inner_wire_b_with_sequence_zone_brightness(
|
||||
inner_base,
|
||||
zone_use,
|
||||
target_mac=mac,
|
||||
settings_obj=settings_obj,
|
||||
groups_model=groups_model,
|
||||
devices_model=devices,
|
||||
)
|
||||
seq_list: List[Dict[str, Any]] = [{"v": "1", "presets": {wire: inner}}]
|
||||
if sel_append:
|
||||
seq_list.append(dict(sel_append))
|
||||
messages = [json.dumps(x, separators=(",", ":")) for x in seq_list]
|
||||
await deliver_json_messages(sender, messages, [mac], devices, delay_s=0.05)
|
||||
|
||||
if not auto:
|
||||
manual_inner = _inner_wire_b_with_sequence_zone_brightness(
|
||||
inner_base,
|
||||
zone_use,
|
||||
target_mac=macs[0] if len(macs) == 1 else None,
|
||||
settings_obj=settings_obj,
|
||||
groups_model=groups_model,
|
||||
devices_model=devices,
|
||||
)
|
||||
if lane_index is not None:
|
||||
from util.beat_driver_route import set_sequence_manual_lane_route
|
||||
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, manual_inner)
|
||||
else:
|
||||
seq_one = [{"v": "1", "presets": {wire: manual_inner}}]
|
||||
if sel_append:
|
||||
seq_one.append(dict(sel_append))
|
||||
sync_beat_route_from_push_sequence(
|
||||
seq_one, target_macs=macs, preserve_parallel_lane_routes=True
|
||||
)
|
||||
|
||||
|
||||
async def _send_lane(
|
||||
lane_index: int,
|
||||
st: Dict[str, Any],
|
||||
ctx: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Apply the current step (select or manual route). Presets must already be on devices."""
|
||||
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
|
||||
sequence_doc: Dict[str, Any] = ctx["sequence_doc"]
|
||||
presets_map: Dict[str, Any] = ctx["presets_map"]
|
||||
zone_doc: Dict[str, Any] = ctx["zone_doc"]
|
||||
devices = ctx["devices"]
|
||||
groups = ctx["groups"]
|
||||
palette_colors: List[Any] = ctx["palette_colors"]
|
||||
num_lanes = ctx["num_lanes"]
|
||||
devices = ctx["devices"]
|
||||
|
||||
if st.get("done"):
|
||||
return
|
||||
@@ -660,14 +641,14 @@ async def _send_lane(
|
||||
display_preset = _display_preset_for_step(preset_id, presets_map, palette_colors)
|
||||
if not display_preset:
|
||||
return
|
||||
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, num_lanes)
|
||||
gids = _group_ids_for_lane_step(sequence_doc, step, lane_index, int(ctx["num_lanes"]))
|
||||
device_names = _resolve_step_device_names(
|
||||
zone_doc, gids, devices, groups, sequence_doc=sequence_doc
|
||||
ctx["zone_doc"], gids, devices, ctx["groups"], sequence_doc=sequence_doc
|
||||
)
|
||||
device_names = _split_device_names_for_lane(
|
||||
device_names,
|
||||
lane_index,
|
||||
num_lanes,
|
||||
int(ctx["num_lanes"]),
|
||||
partition_shared_zone=not _lane_has_non_empty_lanes_group_ids(sequence_doc, lane_index),
|
||||
)
|
||||
if gids and not device_names:
|
||||
@@ -676,6 +657,7 @@ async def _send_lane(
|
||||
from models.transport import get_current_sender
|
||||
from util.beat_driver_route import (
|
||||
clear_sequence_manual_lane_route,
|
||||
mark_sequence_manual_lane_select_sent,
|
||||
set_sequence_manual_lane_route,
|
||||
)
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
@@ -688,44 +670,29 @@ async def _send_lane(
|
||||
if not macs:
|
||||
return
|
||||
|
||||
bulk = ctx.get("_sequence_wire_presets")
|
||||
if isinstance(bulk, dict) and bulk:
|
||||
auto = _coerce_auto(display_preset)
|
||||
wire = str(preset_id)
|
||||
auto = _coerce_auto(display_preset)
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if not sel:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
else:
|
||||
inner = _preset_inner_from_display_preset(display_preset)
|
||||
zone_use = ctx.get("zone_doc") if isinstance(ctx.get("zone_doc"), dict) else {}
|
||||
inner = _inner_wire_b_with_sequence_zone_brightness(
|
||||
inner,
|
||||
zone_use,
|
||||
target_mac=macs[0] if len(macs) == 1 else None,
|
||||
settings_obj=ctx.get("settings"),
|
||||
groups_model=ctx.get("groups"),
|
||||
devices_model=devices,
|
||||
)
|
||||
wire = str(preset_id)
|
||||
if auto:
|
||||
clear_sequence_manual_lane_route(lane_index)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if not sel:
|
||||
return
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||
sel: Dict[str, Any] = {}
|
||||
for n in device_names:
|
||||
if n:
|
||||
sel[str(n)] = [wire]
|
||||
if sel:
|
||||
msg = json.dumps({"v": "1", "select": sel}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
else:
|
||||
set_sequence_manual_lane_route(lane_index, device_names, wire, inner)
|
||||
return
|
||||
|
||||
await _deliver_preset_for_devices(
|
||||
preset_id,
|
||||
display_preset,
|
||||
device_names,
|
||||
devices,
|
||||
lane_index=lane_index,
|
||||
zone_doc=zone_doc,
|
||||
settings_obj=ctx.get("settings"),
|
||||
groups_model=groups,
|
||||
)
|
||||
mark_sequence_manual_lane_select_sent(lane_index)
|
||||
|
||||
|
||||
async def _send_all_lanes(ctx: Dict[str, Any]) -> None:
|
||||
@@ -745,7 +712,7 @@ def _build_ctx(
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
from models.device import Device
|
||||
from models.group import Group
|
||||
from settings import Settings
|
||||
from settings import get_settings
|
||||
|
||||
lanes = [x for x in _normalize_sequence_lanes(sequence_doc) if len(x) > 0]
|
||||
if not lanes:
|
||||
@@ -764,9 +731,9 @@ def _build_ctx(
|
||||
"presets_map": presets_map,
|
||||
"devices": devices,
|
||||
"groups": groups,
|
||||
"settings": Settings(),
|
||||
"settings": get_settings(),
|
||||
"palette_colors": palette_colors,
|
||||
"loop": True,
|
||||
"loop": _coerce_loop(sequence_doc),
|
||||
"advance_mode": "beats",
|
||||
}
|
||||
|
||||
@@ -897,7 +864,294 @@ async def process_active_beat_advance() -> None:
|
||||
else:
|
||||
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
|
||||
if all(s.get("done") for s in lane_states):
|
||||
stop()
|
||||
await stop_playback(clear_devices=True)
|
||||
return
|
||||
|
||||
|
||||
async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
|
||||
"""Stop beat routing and clear driver presets for devices used by this sequence run."""
|
||||
from models.transport import get_current_sender
|
||||
from util.beat_driver_route import clear_sequence_manual_lane_route, update_beat_route
|
||||
from util.driver_delivery import deliver_json_messages
|
||||
|
||||
num_lanes = int(ctx.get("num_lanes") or 0)
|
||||
for i in range(num_lanes):
|
||||
clear_sequence_manual_lane_route(i)
|
||||
update_beat_route({"enabled": False})
|
||||
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return
|
||||
devices = ctx.get("devices")
|
||||
macs = _union_macs_for_sequence(ctx)
|
||||
if not macs:
|
||||
return
|
||||
msg = json.dumps({"v": "1", "clear_presets": True, "save": True}, separators=(",", ":"))
|
||||
await deliver_json_messages(sender, [msg], macs, devices, delay_s=0.05)
|
||||
|
||||
|
||||
def _halt_playback_state() -> Optional[Dict[str, Any]]:
|
||||
"""Drop active run state and cancel simulated beats; return the previous ctx."""
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
ctx: Optional[Dict[str, Any]] = None
|
||||
with _beat_run_lock:
|
||||
ctx = _beat_run
|
||||
_beat_run = None
|
||||
_sim_beat_token += 1
|
||||
st = _sim_beat_task
|
||||
_sim_beat_task = None
|
||||
if st and not st.done():
|
||||
st.cancel()
|
||||
return ctx
|
||||
|
||||
|
||||
async def stop_playback(*, clear_devices: bool = True) -> None:
|
||||
"""Stop sequence playback; optionally clear presets on targeted devices."""
|
||||
clear_pending_play()
|
||||
ctx = _halt_playback_state()
|
||||
if clear_devices and ctx:
|
||||
await _clear_devices_after_sequence(ctx)
|
||||
|
||||
|
||||
def apply_beat_phase_sync(ctx: Dict[str, Any], mode: str) -> Tuple[bool, bool]:
|
||||
"""Align beat counters to music.
|
||||
|
||||
``step`` (default): beat 1 of the current step on the next counted beat.
|
||||
``pass``: restart from step 1 of the sequence pass and re-apply presets.
|
||||
|
||||
Returns ``(ok, resend_lanes)`` — caller should ``await _send_all_lanes(ctx)`` when resend is true.
|
||||
"""
|
||||
if not ctx:
|
||||
return False, False
|
||||
mode_norm = str(mode or "step").strip().lower()
|
||||
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
|
||||
if mode_norm in ("pass", "sequence", "restart"):
|
||||
for st in lane_states:
|
||||
st["stepIdx"] = 0
|
||||
st["beatCount"] = 0
|
||||
st["done"] = False
|
||||
ctx["sequence_loop_beat"] = 0
|
||||
return True, True
|
||||
for st in lane_states:
|
||||
if not st.get("done"):
|
||||
st["beatCount"] = 0
|
||||
return True, False
|
||||
|
||||
|
||||
async def sync_beat_phase(mode: str = "step") -> bool:
|
||||
"""Public entry: align active sequence playback to a musical phase."""
|
||||
with _beat_run_lock:
|
||||
ctx = _beat_run
|
||||
if not ctx:
|
||||
return False
|
||||
ok, resend = apply_beat_phase_sync(ctx, mode)
|
||||
if not ok:
|
||||
return False
|
||||
if resend:
|
||||
await _send_all_lanes(ctx)
|
||||
return True
|
||||
|
||||
|
||||
def _drain_beat_queue() -> None:
|
||||
try:
|
||||
while True:
|
||||
_thread_beat_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
|
||||
def _reset_beat_side_effects() -> None:
|
||||
"""Clear manual routes and queued beats so startup cannot select before presets land."""
|
||||
from util.beat_driver_route import update_beat_route
|
||||
|
||||
update_beat_route({"enabled": False})
|
||||
_drain_beat_queue()
|
||||
|
||||
|
||||
def _sequence_switch_wait_from_settings() -> str:
|
||||
try:
|
||||
from settings import get_settings
|
||||
|
||||
raw = get_settings().get("sequence_switch_wait", "beat")
|
||||
mode = _normalize_wait_for({"wait_for": raw}) or "beat"
|
||||
if mode == "phrase":
|
||||
return "beat"
|
||||
return mode
|
||||
except Exception:
|
||||
return "beat"
|
||||
|
||||
|
||||
def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
"""``beat`` | ``downbeat`` | None (immediate)."""
|
||||
if not isinstance(play_options, dict):
|
||||
return None
|
||||
raw = play_options.get("wait_for")
|
||||
if raw is None:
|
||||
raw = play_options.get("start_on")
|
||||
if raw is None:
|
||||
return None
|
||||
s = str(raw).strip().lower()
|
||||
if s in ("beat", "next_beat"):
|
||||
return "beat"
|
||||
if s in ("downbeat", "next_downbeat"):
|
||||
return "downbeat"
|
||||
return None
|
||||
|
||||
|
||||
def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not isinstance(play_options, dict):
|
||||
return play_options
|
||||
out = dict(play_options)
|
||||
out.pop("wait_for", None)
|
||||
out.pop("start_on", None)
|
||||
return out
|
||||
|
||||
|
||||
def _cancel_pending_beat_waiter() -> None:
|
||||
global _pending_beat_task, _pending_beat_token
|
||||
_pending_beat_token += 1
|
||||
t = _pending_beat_task
|
||||
_pending_beat_task = None
|
||||
if t and not t.done():
|
||||
t.cancel()
|
||||
|
||||
|
||||
def clear_pending_play() -> None:
|
||||
"""Drop a queued sequence start (e.g. user stop)."""
|
||||
global _pending_play
|
||||
with _pending_play_lock:
|
||||
_pending_play = None
|
||||
_cancel_pending_beat_waiter()
|
||||
|
||||
|
||||
def pending_play_status() -> Dict[str, Any]:
|
||||
with _pending_play_lock:
|
||||
p = _pending_play
|
||||
if not p:
|
||||
return {"pending": False}
|
||||
return {
|
||||
"pending": True,
|
||||
"wait_for": p.get("wait_for"),
|
||||
"sequence_id": p.get("sequence_id"),
|
||||
"zone_id": p.get("zone_id"),
|
||||
}
|
||||
|
||||
|
||||
def _beat_phase_from_sources() -> Dict[str, Any]:
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
st = ad_mod.shared_beat_status_snapshot()
|
||||
if st:
|
||||
return dict(st)
|
||||
return dict(_last_thread_beat_phase)
|
||||
|
||||
|
||||
def _beat_is_downbeat_from_sources() -> bool:
|
||||
return bool(_beat_phase_from_sources().get("is_downbeat"))
|
||||
|
||||
|
||||
def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
|
||||
global _sim_beat_counter, _last_thread_beat_phase
|
||||
bpb = max(1, int(beats_per_bar))
|
||||
_sim_beat_counter += 1
|
||||
bar_beat = ((_sim_beat_counter - 1) % bpb) + 1
|
||||
is_downbeat = bar_beat == 1
|
||||
_last_thread_beat_phase = {
|
||||
"bar_beat": bar_beat,
|
||||
"is_downbeat": is_downbeat,
|
||||
}
|
||||
|
||||
|
||||
def _queue_pending_start(
|
||||
zone_id: str,
|
||||
sequence_id: str,
|
||||
profile_id: str,
|
||||
play_options: Optional[Dict[str, Any]],
|
||||
wait_for: str,
|
||||
*,
|
||||
bpm: float,
|
||||
) -> None:
|
||||
global _pending_play
|
||||
clear_pending_play()
|
||||
with _pending_play_lock:
|
||||
_pending_play = {
|
||||
"zone_id": str(zone_id),
|
||||
"sequence_id": str(sequence_id),
|
||||
"profile_id": str(profile_id),
|
||||
"play_options": _play_options_without_wait(play_options),
|
||||
"wait_for": wait_for,
|
||||
}
|
||||
_ensure_pending_beat_waiter(bpm)
|
||||
|
||||
|
||||
def _ensure_pending_beat_waiter(bpm: float) -> None:
|
||||
"""When nothing is playing and audio is off, emit synthetic beats until pending starts."""
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
with _beat_run_lock:
|
||||
if _beat_run:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
global _pending_beat_task, _pending_beat_token
|
||||
t = _pending_beat_task
|
||||
if t and not t.done():
|
||||
t.cancel()
|
||||
_pending_beat_token += 1
|
||||
my_tok = _pending_beat_token
|
||||
_pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))
|
||||
|
||||
|
||||
async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
|
||||
from util import audio_detector as ad_mod
|
||||
|
||||
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
|
||||
while True:
|
||||
with _pending_play_lock:
|
||||
if _pending_beat_token != my_token or _pending_play is None:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
await asyncio.sleep(interval)
|
||||
with _pending_play_lock:
|
||||
if _pending_beat_token != my_token or _pending_play is None:
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
return
|
||||
_mark_simulated_beat_phase()
|
||||
push_thread_beat()
|
||||
|
||||
|
||||
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
|
||||
global _pending_play
|
||||
with _pending_play_lock:
|
||||
pending = _pending_play
|
||||
if not pending:
|
||||
return False
|
||||
wait_for = str(pending.get("wait_for") or "beat").strip().lower()
|
||||
if wait_for == "downbeat" and not is_downbeat:
|
||||
return False
|
||||
_pending_play = None
|
||||
_cancel_pending_beat_waiter()
|
||||
await _start_immediate(
|
||||
pending["zone_id"],
|
||||
pending["sequence_id"],
|
||||
pending["profile_id"],
|
||||
pending.get("play_options"),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Stop server playback state without sending device clear (e.g. before starting another run)."""
|
||||
clear_pending_play()
|
||||
_halt_playback_state()
|
||||
_reset_beat_side_effects()
|
||||
|
||||
|
||||
def push_thread_beat() -> None:
|
||||
@@ -920,6 +1174,12 @@ async def beat_consumer_loop() -> None:
|
||||
from util.beat_driver_route import notify_beat_detected
|
||||
|
||||
for _ in range(n):
|
||||
phase = _beat_phase_from_sources()
|
||||
is_down = bool(phase.get("is_downbeat"))
|
||||
try:
|
||||
await _try_consume_pending_play(is_downbeat=is_down)
|
||||
except Exception as e:
|
||||
print(f"[sequence-playback] pending start: {e}")
|
||||
try:
|
||||
await process_active_beat_advance()
|
||||
except Exception as e:
|
||||
@@ -981,20 +1241,10 @@ async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -
|
||||
return
|
||||
if ad_mod.shared_beat_detector_running():
|
||||
continue
|
||||
_mark_simulated_beat_phase()
|
||||
push_thread_beat()
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
with _beat_run_lock:
|
||||
_beat_run = None
|
||||
_sim_beat_token += 1
|
||||
st = _sim_beat_task
|
||||
_sim_beat_task = None
|
||||
if st and not st.done():
|
||||
st.cancel()
|
||||
|
||||
|
||||
def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||
"""If zone sequence playback is running this sequence id, stop it (e.g. after save/delete)."""
|
||||
sid = str(sequence_id).strip()
|
||||
@@ -1007,7 +1257,11 @@ def stop_if_playing_sequence(sequence_id: str) -> bool:
|
||||
cur = ctx.get("sequence_id")
|
||||
if cur is None or str(cur).strip() != sid:
|
||||
return False
|
||||
stop()
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(stop_playback(clear_devices=True))
|
||||
except RuntimeError:
|
||||
stop()
|
||||
return True
|
||||
|
||||
|
||||
@@ -1016,6 +1270,29 @@ async def start(
|
||||
sequence_id: str,
|
||||
profile_id: str,
|
||||
play_options: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Start immediately, or queue until the next beat / downbeat (``wait_for`` in *play_options*)."""
|
||||
from models.sequence import Sequence
|
||||
|
||||
seq_m = Sequence()
|
||||
sequence_doc = seq_m.read(sequence_id)
|
||||
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
|
||||
raise ValueError("sequence not found")
|
||||
wait_for = _sequence_switch_wait_from_settings()
|
||||
if wait_for:
|
||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||
_queue_pending_start(
|
||||
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
|
||||
)
|
||||
return
|
||||
await _start_immediate(zone_id, sequence_id, profile_id, play_options)
|
||||
|
||||
|
||||
async def _start_immediate(
|
||||
zone_id: str,
|
||||
sequence_id: str,
|
||||
profile_id: str,
|
||||
play_options: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
global _beat_run, _sim_beat_task, _sim_beat_token
|
||||
from models.preset import Preset
|
||||
@@ -1052,14 +1329,11 @@ async def start(
|
||||
ctx["zone_id"] = str(zone_id)
|
||||
ctx["sequence_loop_beat"] = 0
|
||||
|
||||
await _deliver_sequence_presets_bulk(ctx)
|
||||
|
||||
from util.beat_driver_route import update_beat_route
|
||||
|
||||
update_beat_route({"enabled": False})
|
||||
_reset_beat_side_effects()
|
||||
await _prime_all_lanes(ctx)
|
||||
await _deliver_zone_brightness_for_sequence(ctx)
|
||||
with _beat_run_lock:
|
||||
_beat_run = ctx
|
||||
await _send_all_lanes(ctx)
|
||||
|
||||
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
@@ -112,12 +112,6 @@ def parse_args() -> argparse.Namespace:
|
||||
default=0.12,
|
||||
help="Aubio detection threshold",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--silence-gate-db",
|
||||
type=float,
|
||||
default=-58.0,
|
||||
help="Ignore beat triggers when frame RMS is below this dB level",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -131,6 +125,141 @@ def _estimate_bpm(beat_times: Deque[float]) -> float | None:
|
||||
return 60.0 / float(np.median(valid))
|
||||
|
||||
|
||||
def _is_plausible_ioi(
|
||||
last_trigger_s: float,
|
||||
beat_times: Deque[float],
|
||||
now_s: float,
|
||||
*,
|
||||
min_ratio: float = 0.42,
|
||||
max_ratio: float = 2.5,
|
||||
) -> bool:
|
||||
"""Reject double-time / half-time false triggers vs recent median interval."""
|
||||
if last_trigger_s <= 0 or len(beat_times) < 2:
|
||||
return True
|
||||
ioi = now_s - last_trigger_s
|
||||
if ioi <= 0:
|
||||
return False
|
||||
intervals = np.diff(np.array(list(beat_times)[-8:], dtype=np.float64))
|
||||
if intervals.size == 0:
|
||||
return True
|
||||
med = float(np.median(intervals))
|
||||
if med < 0.05:
|
||||
return True
|
||||
return (ioi >= med * min_ratio) and (ioi <= med * max_ratio)
|
||||
|
||||
|
||||
class BarPhaseTracker:
|
||||
"""Track beat-in-bar from downbeat counting (kick hints)."""
|
||||
|
||||
def __init__(self, beats_per_bar: int = 4, kick_conf_min: float = 1.15):
|
||||
self.beats_per_bar = max(1, int(beats_per_bar))
|
||||
self.kick_conf_min = float(kick_conf_min)
|
||||
self.bar_beat = 1
|
||||
self.is_downbeat = True
|
||||
self.confidence = 0.0
|
||||
self._last_downbeat_s = 0.0
|
||||
self._aligned_kicks = 0
|
||||
self._total_beats = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
self.bar_beat = 1
|
||||
self.is_downbeat = True
|
||||
self.confidence = 0.0
|
||||
self._last_downbeat_s = 0.0
|
||||
self._aligned_kicks = 0
|
||||
self._total_beats = 0
|
||||
|
||||
def anchor_downbeat(self, now_s: float) -> None:
|
||||
self.bar_beat = 1
|
||||
self.is_downbeat = True
|
||||
self._last_downbeat_s = float(now_s)
|
||||
self.confidence = max(self.confidence, 0.85)
|
||||
|
||||
def _bar_duration_s(
|
||||
self, bpm: float | None, median_ioi: float | None
|
||||
) -> float | None:
|
||||
if bpm is not None and bpm > 0:
|
||||
return (60.0 / float(bpm)) * self.beats_per_bar
|
||||
if median_ioi is not None and median_ioi > 0:
|
||||
return float(median_ioi) * self.beats_per_bar
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _near_whole_bars(elapsed: float, bar_dur: float, tol: float = 0.14) -> bool:
|
||||
if bar_dur <= 0 or elapsed <= 0:
|
||||
return False
|
||||
n = elapsed / bar_dur
|
||||
nearest = max(1, round(n))
|
||||
return abs(n - nearest) <= tol
|
||||
|
||||
def on_beat(
|
||||
self,
|
||||
now_s: float,
|
||||
beat_type: str,
|
||||
beat_type_conf: float,
|
||||
*,
|
||||
bpm: float | None = None,
|
||||
median_ioi: float | None = None,
|
||||
) -> dict[str, int | float | bool | str]:
|
||||
self._total_beats += 1
|
||||
bar_dur = self._bar_duration_s(bpm, median_ioi)
|
||||
is_kick = (
|
||||
str(beat_type or "").lower() == "kick"
|
||||
and float(beat_type_conf or 0.0) >= self.kick_conf_min
|
||||
)
|
||||
|
||||
downbeat_locked = False
|
||||
if is_kick:
|
||||
if self._last_downbeat_s <= 0 or self._total_beats <= 2:
|
||||
downbeat_locked = True
|
||||
elif bar_dur and self._near_whole_bars(
|
||||
now_s - self._last_downbeat_s, bar_dur
|
||||
):
|
||||
downbeat_locked = True
|
||||
elif is_kick and self.bar_beat >= max(2, self.beats_per_bar - 1):
|
||||
downbeat_locked = True
|
||||
|
||||
prev_bar_beat = int(self.bar_beat)
|
||||
if downbeat_locked:
|
||||
self.bar_beat = 1
|
||||
self.is_downbeat = True
|
||||
self._last_downbeat_s = float(now_s)
|
||||
self._aligned_kicks += 1
|
||||
elif self._total_beats <= 1:
|
||||
self.bar_beat = 1
|
||||
self.is_downbeat = True
|
||||
else:
|
||||
self.bar_beat = (prev_bar_beat % self.beats_per_bar) + 1
|
||||
self.is_downbeat = self.bar_beat == 1
|
||||
|
||||
if self._total_beats >= self.beats_per_bar:
|
||||
bars_seen = max(1, self._total_beats // self.beats_per_bar)
|
||||
self.confidence = min(1.0, self._aligned_kicks / bars_seen)
|
||||
|
||||
return {
|
||||
"bar_beat": int(self.bar_beat),
|
||||
"beats_per_bar": int(self.beats_per_bar),
|
||||
"is_downbeat": bool(self.is_downbeat),
|
||||
"phase_confidence": round(float(self.confidence), 3),
|
||||
"bar_phase_readout": f"{int(self.bar_beat)}/{int(self.beats_per_bar)}",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_bpm(
|
||||
beat_times: Deque[float],
|
||||
aubio_bpm: float | None,
|
||||
) -> float | None:
|
||||
estimated = _estimate_bpm(beat_times)
|
||||
if estimated is None:
|
||||
return aubio_bpm
|
||||
if aubio_bpm is None or aubio_bpm <= 0:
|
||||
return estimated
|
||||
ratio = float(aubio_bpm) / estimated
|
||||
if ratio > 1.75 or ratio < 0.57:
|
||||
return estimated
|
||||
return estimated
|
||||
|
||||
|
||||
def _load_aubio_if_needed(mode: str):
|
||||
if mode == "custom":
|
||||
return None
|
||||
@@ -170,6 +299,8 @@ class BeatDetectRuntime:
|
||||
)
|
||||
self.last_trigger_s = 0.0
|
||||
self.debounce_s = float(args.min_ioi_ms) / 1000.0
|
||||
bpb = int(getattr(args, "beats_per_bar", 4) or 4)
|
||||
self.bar_phase = BarPhaseTracker(beats_per_bar=bpb)
|
||||
|
||||
def setup(self, sample_rate: int):
|
||||
self.sample_rate = int(sample_rate)
|
||||
@@ -192,13 +323,37 @@ class BeatDetectRuntime:
|
||||
self.beat_times.clear()
|
||||
self.tempo = None
|
||||
if self.aubio is not None:
|
||||
self.tempo = self.aubio.tempo(
|
||||
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
|
||||
)
|
||||
if hasattr(self.tempo, "set_threshold"):
|
||||
self.tempo.set_threshold(float(self.args.aubio_threshold))
|
||||
if hasattr(self.tempo, "set_minioi_ms"):
|
||||
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
|
||||
self._init_aubio_tempo(win_size)
|
||||
|
||||
def _init_aubio_tempo(self, win_size: int):
|
||||
self.tempo = self.aubio.tempo(
|
||||
self.args.aubio_method, win_size, self.frame_size, self.sample_rate
|
||||
)
|
||||
if hasattr(self.tempo, "set_threshold"):
|
||||
self.tempo.set_threshold(float(self.args.aubio_threshold))
|
||||
if hasattr(self.tempo, "set_minioi_ms"):
|
||||
self.tempo.set_minioi_ms(float(self.args.min_ioi_ms))
|
||||
|
||||
def reset_tempo_state(self) -> None:
|
||||
"""Clear tempo/aubio history without losing bar phase."""
|
||||
self.baseline = 1e-6
|
||||
if self.prev_mag is not None:
|
||||
self.prev_mag[:] = 0.0
|
||||
self.beat_times.clear()
|
||||
self.last_trigger_s = 0.0
|
||||
if self.aubio is not None and self.sample_rate > 0:
|
||||
win_size = max(1024, self.frame_size * max(2, self.args.win_mult))
|
||||
self._init_aubio_tempo(win_size)
|
||||
|
||||
def reset_state(self):
|
||||
"""Full reset (manual): tempo history and bar phase."""
|
||||
self.reset_tempo_state()
|
||||
self.bar_phase.reset()
|
||||
|
||||
def anchor_bar_phase(self, now_s: float | None = None) -> None:
|
||||
if now_s is None:
|
||||
now_s = time.time()
|
||||
self.bar_phase.anchor_downbeat(now_s)
|
||||
|
||||
def _classify_hit(self, mag: np.ndarray):
|
||||
total = float(np.mean(mag) + 1e-9)
|
||||
@@ -227,8 +382,6 @@ class BeatDetectRuntime:
|
||||
f32 = frame.astype(np.float32)
|
||||
rms = float(np.sqrt(np.mean(f32 * f32) + 1e-12))
|
||||
db = 20.0 * np.log10(max(rms, 1e-12))
|
||||
if db < float(self.args.silence_gate_db):
|
||||
return None
|
||||
mag = np.abs(np.fft.rfft(f32 * self.window)).astype(np.float32)
|
||||
band_energy = float(np.mean(mag[self.band_mask]))
|
||||
flux = float(np.mean(np.maximum(0.0, mag - self.prev_mag)))
|
||||
@@ -260,14 +413,30 @@ class BeatDetectRuntime:
|
||||
should_trigger = aubio_hit
|
||||
else:
|
||||
should_trigger = custom_hit or aubio_hit
|
||||
if should_trigger and not _is_plausible_ioi(
|
||||
self.last_trigger_s, self.beat_times, now_s
|
||||
):
|
||||
should_trigger = False
|
||||
if not should_trigger:
|
||||
return None
|
||||
|
||||
self.last_trigger_s = now_s
|
||||
self.beat_times.append(now_s)
|
||||
bpm = aubio_bpm if aubio_bpm is not None else _estimate_bpm(self.beat_times)
|
||||
bpm = _resolve_bpm(self.beat_times, aubio_bpm)
|
||||
strength = score / max(1e-9, self.baseline)
|
||||
beat_type, beat_type_conf = self._classify_hit(mag)
|
||||
median_ioi = None
|
||||
if len(self.beat_times) >= 2:
|
||||
intervals = np.diff(np.array(self.beat_times, dtype=np.float64))
|
||||
if intervals.size > 0:
|
||||
median_ioi = float(np.median(intervals))
|
||||
phase = self.bar_phase.on_beat(
|
||||
now_s,
|
||||
beat_type,
|
||||
beat_type_conf,
|
||||
bpm=bpm,
|
||||
median_ioi=median_ioi,
|
||||
)
|
||||
if self.args.mode == "custom":
|
||||
src = "custom"
|
||||
elif self.args.mode == "aubio":
|
||||
@@ -288,6 +457,7 @@ class BeatDetectRuntime:
|
||||
"beat_type": beat_type,
|
||||
"beat_type_confidence": beat_type_conf,
|
||||
"db": db,
|
||||
**phase,
|
||||
}
|
||||
|
||||
|
||||
|
||||
61
tests/test_audio_reset_tracking.py
Normal file
61
tests/test_audio_reset_tracking.py
Normal 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
70
tests/test_bar_phase.py
Normal 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
|
||||
28
tests/test_beat_detect_ioi.py
Normal file
28
tests/test_beat_detect_ioi.py
Normal 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
|
||||
105
tests/test_beat_driver_route_suppress.py
Normal file
105
tests/test_beat_driver_route_suppress.py
Normal 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")]
|
||||
@@ -352,19 +352,27 @@ def test_settings_controller(server):
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 11})
|
||||
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 11})
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = c.put(f"{base_url}/settings/settings", json={"wifi_channel": 12})
|
||||
resp = c.put(f"{base_url}/settings", json={"wifi_channel": 12})
|
||||
assert resp.status_code == 400
|
||||
|
||||
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 42})
|
||||
resp = c.put(f"{base_url}/settings", json={"global_brightness": 42})
|
||||
assert resp.status_code == 200
|
||||
resp = c.get(f"{base_url}/settings")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json().get("global_brightness") == 42
|
||||
|
||||
resp = c.put(f"{base_url}/settings/settings", json={"global_brightness": 300})
|
||||
resp = c.put(
|
||||
f"{base_url}/settings",
|
||||
json={"sequence_switch_wait": "downbeat"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
resp = c.get(f"{base_url}/settings")
|
||||
assert resp.json().get("sequence_switch_wait") == "downbeat"
|
||||
|
||||
resp = c.put(f"{base_url}/settings", json={"global_brightness": 300})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
|
||||
36
tests/test_pattern_direction.py
Normal file
36
tests/test_pattern_direction.py
Normal 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
|
||||
43
tests/test_sequence_beat_phase_sync.py
Normal file
43
tests/test_sequence_beat_phase_sync.py
Normal 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
|
||||
88
tests/test_sequence_pending_start.py
Normal file
88
tests/test_sequence_pending_start.py
Normal 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()
|
||||
|
||||
30
tests/test_sequence_playback_loop.py
Normal file
30
tests/test_sequence_playback_loop.py
Normal 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"]
|
||||
22
tests/test_ui_settings.py
Normal file
22
tests/test_ui_settings.py
Normal 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")
|
||||
Reference in New Issue
Block a user