feat(sequences): multi-lane playback and per-lane manual beats
- Add sequence_playback with beat and time advance, zone targeting fixes - Per-lane manual beat routing in beat_driver_route (parallel lanes) - Sequence API, editor JS, fix sequence model filename, tests Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
148
src/models/sequence.py
Normal file
148
src/models/sequence.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from models.model import Model
|
||||
|
||||
|
||||
class Sequence(Model):
|
||||
def load(self):
|
||||
super().load()
|
||||
self._migrate_after_load()
|
||||
|
||||
def _migrate_after_load(self):
|
||||
try:
|
||||
from models.profile import Profile
|
||||
|
||||
profiles = Profile()
|
||||
profile_list = profiles.list()
|
||||
default_profile_id = profile_list[0] if profile_list else None
|
||||
except Exception:
|
||||
default_profile_id = None
|
||||
|
||||
changed = False
|
||||
for _sid, doc in list(self.items()):
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if not isinstance(doc.get("steps"), list):
|
||||
presets = doc.get("presets")
|
||||
if isinstance(presets, list) and presets:
|
||||
doc["steps"] = [
|
||||
{"preset_id": str(p), "group_ids": []} for p in presets
|
||||
]
|
||||
else:
|
||||
doc["steps"] = []
|
||||
changed = True
|
||||
if "step_duration_ms" not in doc:
|
||||
dur = doc.get("sequence_duration")
|
||||
doc["step_duration_ms"] = (
|
||||
int(dur) if isinstance(dur, (int, float)) else 3000
|
||||
)
|
||||
changed = True
|
||||
if "loop" not in doc:
|
||||
doc["loop"] = bool(doc.get("sequence_loop", False))
|
||||
changed = True
|
||||
if "name" not in doc:
|
||||
doc["name"] = str(doc.get("group_name") or "")
|
||||
changed = True
|
||||
if "profile_id" not in doc and default_profile_id is not None:
|
||||
doc["profile_id"] = str(default_profile_id)
|
||||
changed = True
|
||||
if not isinstance(doc.get("lanes"), list):
|
||||
steps = doc.get("steps")
|
||||
if isinstance(steps, list) and steps:
|
||||
doc["lanes"] = [list(steps)]
|
||||
else:
|
||||
doc["lanes"] = [[]]
|
||||
changed = True
|
||||
if "group_ids" not in doc or not isinstance(doc.get("group_ids"), list):
|
||||
doc["group_ids"] = []
|
||||
changed = True
|
||||
if doc.get("advance_mode") not in ("time", "beats"):
|
||||
doc["advance_mode"] = "time"
|
||||
changed = True
|
||||
if "sequence_transition" not in doc:
|
||||
doc["sequence_transition"] = 500
|
||||
changed = True
|
||||
# Ensure each step has beats (beat-based advance); default 1
|
||||
for lane in doc.get("lanes") or []:
|
||||
if not isinstance(lane, list):
|
||||
continue
|
||||
for step in lane:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
if "beats" not in step:
|
||||
step["beats"] = 1
|
||||
changed = True
|
||||
# Per-lane group ids (parallel to ``lanes``)
|
||||
lanes_list = [x for x in (doc.get("lanes") or []) if isinstance(x, list)]
|
||||
n_lanes = len(lanes_list)
|
||||
lg = doc.get("lanes_group_ids")
|
||||
if n_lanes and (not isinstance(lg, list) or len(lg) != n_lanes):
|
||||
shared = doc.get("group_ids") if isinstance(doc.get("group_ids"), list) else []
|
||||
shared_s = [str(x).strip() for x in shared if x is not None and str(x).strip()]
|
||||
if n_lanes == 1 and lanes_list[0]:
|
||||
first = lanes_list[0][0] if isinstance(lanes_list[0][0], dict) else {}
|
||||
step_g = (
|
||||
first.get("group_ids")
|
||||
if isinstance(first.get("group_ids"), list)
|
||||
else []
|
||||
)
|
||||
step_s = [
|
||||
str(x).strip() for x in step_g if x is not None and str(x).strip()
|
||||
]
|
||||
doc["lanes_group_ids"] = [step_s if step_s else list(shared_s)]
|
||||
else:
|
||||
doc["lanes_group_ids"] = [list(shared_s) for _ in range(n_lanes)]
|
||||
changed = True
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
def create(self, profile_id=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"name": "",
|
||||
"profile_id": str(profile_id) if profile_id is not None else None,
|
||||
"group_ids": [],
|
||||
"lanes": [[]],
|
||||
"lanes_group_ids": [[]],
|
||||
"advance_mode": "time",
|
||||
"steps": [],
|
||||
"step_duration_ms": 3000,
|
||||
"sequence_transition": 500,
|
||||
"loop": True,
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
data = dict(data)
|
||||
steps = data.get("steps")
|
||||
lanes = data.get("lanes")
|
||||
if isinstance(steps, list) and steps:
|
||||
lanes_ok = (
|
||||
isinstance(lanes, list)
|
||||
and lanes
|
||||
and any(isinstance(x, list) and len(x) > 0 for x in lanes)
|
||||
)
|
||||
if not lanes_ok:
|
||||
data["lanes"] = [list(steps)]
|
||||
self[id_str].update(data)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self.pop(id_str)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
@@ -1,44 +0,0 @@
|
||||
from models.model import Model
|
||||
|
||||
class Sequence(Model):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def create(self, group_name="", preset_names=None):
|
||||
next_id = self.get_next_id()
|
||||
self[next_id] = {
|
||||
"group_name": group_name,
|
||||
"presets": preset_names if preset_names else [],
|
||||
"sequence_duration": 3000, # Duration per preset in ms
|
||||
"sequence_transition": 500, # Transition time in ms
|
||||
"sequence_loop": False,
|
||||
"sequence_repeat_count": 0, # 0 = infinite
|
||||
"sequence_active": False,
|
||||
"sequence_index": 0,
|
||||
"sequence_start_time": 0
|
||||
}
|
||||
self.save()
|
||||
return next_id
|
||||
|
||||
def read(self, id):
|
||||
id_str = str(id)
|
||||
return self.get(id_str, None)
|
||||
|
||||
def update(self, id, data):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self[id_str].update(data)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def delete(self, id):
|
||||
id_str = str(id)
|
||||
if id_str not in self:
|
||||
return False
|
||||
self.pop(id_str)
|
||||
self.save()
|
||||
return True
|
||||
|
||||
def list(self):
|
||||
return list(self.keys())
|
||||
Reference in New Issue
Block a user