From 964cfc6d91b6b55e90a41e4a3883fb95b438567e Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 17 May 2026 18:32:10 +1200 Subject: [PATCH] feat(audio-sequences): beat phase sync and aligned playback Add bar-phase tracking, audio reset/anchor APIs, BPM holdover, beat-phase sequence switching, sync-phase endpoint, and sample sequence data. Co-authored-by: Cursor --- db/preset.json | 2 +- db/sequence.json | 2 +- src/controllers/device.py | 4 +- src/controllers/group.py | 4 +- src/controllers/sequence.py | 45 +- src/controllers/settings.py | 24 +- src/controllers/zone.py | 2 + src/main.py | 110 ++++- src/models/zone.py | 46 +- src/settings.py | 38 +- src/util/audio_detector.py | 243 ++++++++++- src/util/audio_run_persist.py | 56 ++- src/util/beat_driver_route.py | 63 ++- src/util/sequence_playback.py | 770 +++++++++++++++++++++++----------- 14 files changed, 1117 insertions(+), 292 deletions(-) diff --git a/db/preset.json b/db/preset.json index 12789a7..b910a21 100644 --- a/db/preset.json +++ b/db/preset.json @@ -1 +1 @@ -{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 8, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null]}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}} \ No newline at end of file +{"1": {"name": "on", "pattern": "on", "colors": ["#FFFFFF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "2": {"name": "off", "pattern": "off", "colors": [], "brightness": 0, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "3": {"name": "rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 100, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "4": {"name": "transition", "pattern": "transition", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 255, "delay": 300, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#000000", "manual_beat_n": 1}, "5": {"name": "chase", "pattern": "chase", "colors": ["#FFFF00", "#FF00FF"], "brightness": 8, "delay": 200, "auto": false, "n1": 30, "n2": 30, "n3": 30, "n4": 30, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [3, 4], "manual_beat_n": 1, "background": "#000000", "background_palette_ref": null}, "6": {"name": "pulse", "pattern": "pulse", "colors": ["#FF00FF"], "brightness": 255, "delay": 1000, "auto": false, "n1": 100, "n2": 0, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [4], "background_color": "#ec0909", "background_palette_ref": 8, "manual_beat_n": 1, "background": "#050500"}, "7": {"name": "circle", "pattern": "circle", "colors": ["#FFA500", "#800080"], "brightness": 255, "delay": 200, "auto": true, "n1": 2, "n2": 10, "n3": 2, "n4": 5, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "8": {"name": "blink", "pattern": "blink", "colors": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00"], "brightness": 255, "delay": 1000, "auto": false, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null]}, "9": {"name": "warm white", "pattern": "on", "colors": ["#FFF5E6"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "10": {"name": "cool white", "pattern": "on", "colors": ["#E6F2FF"], "brightness": 200, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "11": {"name": "red", "pattern": "on", "colors": ["#FF0000"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "12": {"name": "blue", "pattern": "on", "colors": ["#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "13": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "14": {"name": "pulse slow", "pattern": "pulse", "colors": ["#FF6600"], "brightness": 255, "delay": 800, "auto": true, "n1": 2000, "n2": 1000, "n3": 2000, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "15": {"name": "blink red green", "pattern": "blink", "colors": ["#FF0000", "#00FF00"], "brightness": 255, "delay": 500, "auto": true, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "30": {"name": "rainbow slow", "pattern": "colour_cycle", "colors": [], "brightness": 255, "delay": 150, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "31": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "32": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "33": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "34": {"name": "DJ Rainbow", "pattern": "colour_cycle", "colors": [], "brightness": 220, "delay": 60, "n1": 12, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "2"}, "35": {"name": "DJ Single Color", "pattern": "on", "colors": ["#ff00ff"], "brightness": 220, "delay": 100, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "36": {"name": "DJ Transition", "pattern": "transition", "colors": ["#ff0000", "#00ff00", "#0000ff"], "brightness": 220, "delay": 250, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "2"}, "37": {"name": "tranistion2", "pattern": "transition", "colors": ["#FF0000", "#FFFF00", "#FF00FF"], "brightness": 128, "delay": 1000, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [0, 3, 4]}, "38": {"name": "Colour Cycle", "pattern": "colour_cycle", "colors": ["#FF0000", "#0000FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 1, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "39": {"name": "flicker", "pattern": "flicker", "colors": ["#ae00ff"], "brightness": 255, "delay": 50, "auto": false, "n1": 100, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "40": {"name": "flame", "pattern": "flame", "colors": ["#ffc800"], "brightness": 128, "delay": 50, "auto": true, "n1": 35, "n2": 2600, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "background_palette_ref": null, "manual_beat_n": 1}, "41": {"name": "twinkle", "pattern": "twinkle", "colors": ["#78C8FF", "#508CFF", "#B478FF", "#64DCE8", "#A0C8FF"], "brightness": 255, "delay": 100, "auto": true, "n1": 150, "n2": 20, "n3": 0, "n4": 10, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null, null, null], "background": "#000000", "manual_beat_n": 1, "background_palette_ref": null}, "42": {"name": "radiate", "pattern": "radiate", "colors": ["#a600ff"], "brightness": 255, "delay": 2000, "auto": false, "n1": 60, "n2": 200, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "manual_beat_n": 1, "background": "#050500", "background_palette_ref": 8}, "43": {"name": "test meteor rain", "pattern": "meteor", "colors": ["#FF5000", "#0080FF"], "brightness": 200, "delay": 40, "auto": true, "n1": 50, "n2": 1, "n3": 200, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null]}, "44": {"name": "test scanner", "pattern": "meteor", "colors": ["#FF0000"], "brightness": 255, "delay": 30, "auto": true, "n1": 4, "n2": 2, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1"}, "45": {"name": "test gradient scroll", "pattern": "colour_cycle", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 220, "delay": 60, "auto": true, "n1": 2, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "46": {"name": "test comet dual", "pattern": "meteor", "colors": ["#FFAA00", "#00AAFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 8, "n2": 1, "n3": 3, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#0b000f", "manual_beat_n": 1}, "47": {"name": "test sparkle trail", "pattern": "sparkle", "colors": ["#88CCFF", "#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 24, "n2": 210, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "49": {"name": "test plasma", "pattern": "plasma", "colors": ["#FF0066"], "brightness": 200, "delay": 60, "auto": true, "n1": 6, "n2": 2, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "51": {"name": "test bar graph", "pattern": "bar_graph", "colors": ["#00FF00", "#102010"], "brightness": 200, "delay": 60, "auto": true, "n1": 60, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "53": {"name": "test strobe burst", "pattern": "strobe_burst", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": false, "n1": 2, "n2": 10, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "54": {"name": "test rain drops", "pattern": "rain_drops", "colors": ["#7cbdfe"], "brightness": 200, "delay": 60, "auto": true, "n1": 32, "n2": 3, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null], "background": "#000000", "manual_beat_n": 1}, "55": {"name": "test fireflies", "pattern": "sparkle", "colors": ["#FFD060", "#90FF90"], "brightness": 200, "delay": 60, "auto": false, "n1": 6, "n2": 8, "n3": 0, "n4": 0, "n5": 0, "n6": 2, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null], "background": "#000000", "manual_beat_n": 1}, "56": {"name": "test clock sweep", "pattern": "clock_sweep", "colors": ["#FFFFFF", "#202020"], "brightness": 200, "delay": 60, "auto": true, "n1": 1, "n2": 5, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "57": {"name": "test marquee", "pattern": "chase", "colors": ["#FFFFFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 2, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1"}, "58": {"name": "test aurora", "pattern": "aurora", "colors": ["#2CC88C", "#5078FF", "#A050DC"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 40, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "59": {"name": "test snowfall", "pattern": "particles", "colors": ["#FFFFFF", "#B0DCFF"], "brightness": 200, "delay": 60, "auto": true, "n1": 20, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "61": {"name": "test orbit", "pattern": "orbit", "colors": ["#FFFFFF", "#00B4FF", "#FF0077"], "brightness": 200, "delay": 60, "auto": true, "n1": 3, "n2": 1, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null]}, "62": {"name": "test palette morph", "pattern": "palette_morph", "colors": ["#FF0000", "#00FF00", "#0000FF"], "brightness": 200, "delay": 60, "auto": true, "n1": 1200, "n2": 200, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1"}, "63": {"name": "off", "pattern": "off", "colors": [], "background": "#000000", "brightness": 0, "delay": 0, "n1": 0, "n2": 0, "n3": 0, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "manual_beat_n": 1, "profile_id": "2", "palette_refs": [], "auto": true}, "64": {"name": "winter icicles", "pattern": "icicles", "colors": ["#F0F8FF", "#9ECFFF", "#FFFFFF"], "brightness": 220, "delay": 80, "auto": true, "n1": 14, "n2": 11, "n3": 1, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A1520", "manual_beat_n": 1}, "65": {"name": "winter blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#CDE8FF", "#AACCF5"], "brightness": 220, "delay": 45, "auto": true, "n1": 110, "n2": 2, "n3": 140, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050810", "manual_beat_n": 1}, "66": {"name": "winter rime frost", "pattern": "rime", "colors": ["#E8F4FF", "#FFFFFF", "#B8DCF8"], "brightness": 200, "delay": 120, "auto": true, "n1": 40, "n2": 18, "n3": 4, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#071018", "manual_beat_n": 1}, "67": {"name": "winter northern wave", "pattern": "aurora", "colors": ["#183050", "#5090C8", "#C8E8FF"], "brightness": 200, "delay": 90, "auto": true, "n1": 22, "n2": 210, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#060C18", "manual_beat_n": 1}, "68": {"name": "winter candle glow", "pattern": "candle_glow", "colors": ["#FF8020", "#FFC080", "#FFA040"], "brightness": 180, "delay": 70, "auto": true, "n1": 4, "n2": 3, "n3": 120, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#0A0508", "manual_beat_n": 1}, "69": {"name": "winter starfall", "pattern": "particles", "colors": ["#FFFFFF", "#C8E8FF", "#FFF8E0"], "brightness": 220, "delay": 55, "auto": true, "n1": 16, "n2": 2, "n3": 12, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "70": {"name": "winter ice sparkle", "pattern": "sparkle", "colors": ["#E8F4FF", "#B0DCFF", "#FFFFFF"], "brightness": 210, "delay": 50, "auto": true, "n1": 70, "n2": 165, "n3": 1, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081018", "manual_beat_n": 1}, "71": {"name": "test northern wave", "pattern": "aurora", "colors": ["#204060", "#4080C0", "#D0F0FF"], "brightness": 200, "delay": 75, "auto": true, "n1": 18, "n2": 190, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#050A14", "manual_beat_n": 1}, "72": {"name": "test candle glow", "pattern": "candle_glow", "colors": ["#FF7020", "#FFD090", "#FFB060"], "brightness": 190, "delay": 65, "auto": true, "n1": 3, "n2": 4, "n3": 100, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#080408", "manual_beat_n": 1}, "73": {"name": "test starfall", "pattern": "particles", "colors": ["#FFFFFF", "#B8D8FF", "#FFF0C0"], "brightness": 220, "delay": 50, "auto": true, "n1": 20, "n2": 3, "n3": 10, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#030610", "manual_beat_n": 1}, "74": {"name": "test ice sparkle", "pattern": "sparkle", "colors": ["#F0F8FF", "#A8D0FF", "#FFFFFF"], "brightness": 215, "delay": 45, "auto": true, "n1": 85, "n2": 150, "n3": 2, "n4": 0, "n5": 0, "n6": 1, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#06121A", "manual_beat_n": 1}, "75": {"name": "test icicles", "pattern": "icicles", "colors": ["#E8F4FF", "#88C0FF", "#FFFFFF"], "brightness": 220, "delay": 70, "auto": true, "n1": 12, "n2": 9, "n3": 2, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#081420", "manual_beat_n": 1}, "76": {"name": "test blizzard", "pattern": "blizzard", "colors": ["#FFFFFF", "#D0E8FF", "#B0C8F0"], "brightness": 220, "delay": 40, "auto": true, "n1": 95, "n2": 3, "n3": 128, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#040810", "manual_beat_n": 1}, "77": {"name": "test rime", "pattern": "rime", "colors": ["#E0F0FF", "#FFFFFF", "#A8D0F0"], "brightness": 205, "delay": 100, "auto": true, "n1": 35, "n2": 20, "n3": 5, "n4": 0, "n5": 0, "n6": 0, "n7": 0, "n8": 0, "profile_id": "1", "palette_refs": [null, null, null], "background": "#061018", "manual_beat_n": 1}} \ No newline at end of file diff --git a/db/sequence.json b/db/sequence.json index 9e26dfe..41ecca8 100644 --- a/db/sequence.json +++ b/db/sequence.json @@ -1 +1 @@ -{} \ No newline at end of file +{"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}} \ No newline at end of file diff --git a/src/controllers/device.py b/src/controllers/device.py index 98efe03..5163d9f 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -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): diff --git a/src/controllers/group.py b/src/controllers/group.py index b25aa68..697afc6 100644 --- a/src/controllers/group.py +++ b/src/controllers/group.py @@ -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): diff --git a/src/controllers/sequence.py b/src/controllers/sequence.py index 18ff721..4b641f7 100644 --- a/src/controllers/sequence.py +++ b/src/controllers/sequence.py @@ -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: diff --git a/src/controllers/settings.py b/src/controllers/settings.py index 9bfdb79..7e10093 100644 --- a/src/controllers/settings.py +++ b/src/controllers/settings.py @@ -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() diff --git a/src/controllers/zone.py b/src/controllers/zone.py index cc811e1..23998eb 100644 --- a/src/controllers/zone.py +++ b/src/controllers/zone.py @@ -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("/") async def get_zone(request, id): + zones.load() z = zones.read(id) if z: return json.dumps(z), 200, {"Content-Type": "application/json"} diff --git a/src/main.py b/src/main.py index 3134d10..dd936b8 100644 --- a/src/main.py +++ b/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") diff --git a/src/models/zone.py b/src/models/zone.py index de155bd..46f5bb2 100644 --- a/src/models/zone.py +++ b/src/models/zone.py @@ -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 diff --git a/src/settings.py b/src/settings.py index 26246c3..32c8928 100644 --- a/src/settings.py +++ b/src/settings.py @@ -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 diff --git a/src/util/audio_detector.py b/src/util/audio_detector.py index 7c23f75..610668b 100644 --- a/src/util/audio_detector.py +++ b/src/util/audio_detector.py @@ -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 diff --git a/src/util/audio_run_persist.py b/src/util/audio_run_persist.py index d96d8e0..069f4c0 100644 --- a/src/util/audio_run_persist.py +++ b/src/util/audio_run_persist.py @@ -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: diff --git a/src/util/beat_driver_route.py b/src/util/beat_driver_route.py index a3f20be..932dc3f 100644 --- a/src/util/beat_driver_route.py +++ b/src/util/beat_driver_route.py @@ -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: diff --git a/src/util/sequence_playback.py b/src/util/sequence_playback.py index 0f60895..237d908 100644 --- a/src/util/sequence_playback.py +++ b/src/util/sequence_playback.py @@ -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()