livepilot 1.10.6 → 1.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +168 -0
- package/README.md +12 -10
- package/bin/livepilot.js +168 -30
- package/installer/install.js +117 -11
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +215 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +132 -33
- package/mcp_server/atlas/tools.py +56 -15
- package/mcp_server/composer/layer_planner.py +27 -0
- package/mcp_server/composer/prompt_parser.py +15 -6
- package/mcp_server/connection.py +11 -3
- package/mcp_server/corpus/__init__.py +14 -4
- package/mcp_server/creative_constraints/tools.py +206 -33
- package/mcp_server/experiment/engine.py +7 -9
- package/mcp_server/hook_hunter/analyzer.py +62 -9
- package/mcp_server/hook_hunter/tools.py +60 -9
- package/mcp_server/m4l_bridge.py +68 -12
- package/mcp_server/musical_intelligence/detectors.py +32 -0
- package/mcp_server/performance_engine/tools.py +112 -29
- package/mcp_server/preview_studio/engine.py +89 -8
- package/mcp_server/preview_studio/tools.py +22 -6
- package/mcp_server/project_brain/automation_graph.py +71 -19
- package/mcp_server/project_brain/builder.py +2 -0
- package/mcp_server/project_brain/tools.py +55 -5
- package/mcp_server/reference_engine/profile_builder.py +129 -3
- package/mcp_server/reference_engine/tools.py +47 -6
- package/mcp_server/runtime/execution_router.py +66 -2
- package/mcp_server/runtime/mcp_dispatch.py +75 -3
- package/mcp_server/runtime/remote_commands.py +10 -2
- package/mcp_server/sample_engine/analyzer.py +131 -4
- package/mcp_server/sample_engine/critics.py +29 -8
- package/mcp_server/sample_engine/models.py +42 -4
- package/mcp_server/sample_engine/tools.py +48 -14
- package/mcp_server/semantic_moves/__init__.py +1 -0
- package/mcp_server/semantic_moves/compiler.py +9 -1
- package/mcp_server/semantic_moves/device_creation_compilers.py +47 -0
- package/mcp_server/semantic_moves/mix_compilers.py +170 -0
- package/mcp_server/semantic_moves/mix_moves.py +1 -1
- package/mcp_server/semantic_moves/models.py +5 -0
- package/mcp_server/semantic_moves/sound_design_compilers.py +22 -59
- package/mcp_server/semantic_moves/tools.py +15 -4
- package/mcp_server/semantic_moves/transition_compilers.py +12 -19
- package/mcp_server/server.py +75 -5
- package/mcp_server/services/singletons.py +68 -0
- package/mcp_server/session_continuity/models.py +4 -0
- package/mcp_server/session_continuity/tracker.py +14 -1
- package/mcp_server/song_brain/builder.py +110 -12
- package/mcp_server/song_brain/tools.py +77 -13
- package/mcp_server/sound_design/tools.py +112 -1
- package/mcp_server/splice_client/client.py +29 -8
- package/mcp_server/stuckness_detector/detector.py +90 -0
- package/mcp_server/stuckness_detector/tools.py +41 -0
- package/mcp_server/tools/_agent_os_engine/critics.py +24 -0
- package/mcp_server/tools/_composition_engine/__init__.py +2 -2
- package/mcp_server/tools/_composition_engine/harmony.py +90 -0
- package/mcp_server/tools/_composition_engine/sections.py +47 -4
- package/mcp_server/tools/_harmony_engine.py +52 -8
- package/mcp_server/tools/_research_engine.py +98 -19
- package/mcp_server/tools/_theory_engine.py +138 -9
- package/mcp_server/tools/agent_os.py +20 -3
- package/mcp_server/tools/analyzer.py +105 -6
- package/mcp_server/tools/clips.py +46 -1
- package/mcp_server/tools/composition.py +66 -23
- package/mcp_server/tools/devices.py +22 -1
- package/mcp_server/tools/harmony.py +115 -14
- package/mcp_server/tools/midi_io.py +23 -1
- package/mcp_server/tools/mixing.py +35 -1
- package/mcp_server/tools/motif.py +49 -3
- package/mcp_server/tools/research.py +24 -0
- package/mcp_server/tools/theory.py +108 -16
- package/mcp_server/tools/tracks.py +1 -1
- package/mcp_server/tools/transport.py +1 -1
- package/mcp_server/transition_engine/critics.py +18 -11
- package/mcp_server/translation_engine/tools.py +8 -4
- package/package.json +25 -3
- package/remote_script/LivePilot/__init__.py +77 -2
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/remote_script/LivePilot/browser.py +16 -6
- package/remote_script/LivePilot/clips.py +69 -0
- package/remote_script/LivePilot/devices.py +10 -5
- package/remote_script/LivePilot/mixing.py +117 -0
- package/remote_script/LivePilot/notes.py +13 -2
- package/remote_script/LivePilot/router.py +13 -1
- package/remote_script/LivePilot/server.py +51 -13
- package/remote_script/LivePilot/version_detect.py +7 -4
- package/server.json +20 -0
- package/.claude-plugin/marketplace.json +0 -21
- package/.mcpbignore +0 -57
- package/AGENTS.md +0 -46
- package/CODE_OF_CONDUCT.md +0 -27
- package/CONTRIBUTING.md +0 -131
- package/SECURITY.md +0 -48
- package/livepilot/.Codex-plugin/plugin.json +0 -8
- package/livepilot/.claude-plugin/plugin.json +0 -8
- package/livepilot/agents/livepilot-producer/AGENT.md +0 -313
- package/livepilot/commands/arrange.md +0 -47
- package/livepilot/commands/beat.md +0 -77
- package/livepilot/commands/evaluate.md +0 -49
- package/livepilot/commands/memory.md +0 -22
- package/livepilot/commands/mix.md +0 -44
- package/livepilot/commands/perform.md +0 -42
- package/livepilot/commands/session.md +0 -13
- package/livepilot/commands/sounddesign.md +0 -43
- package/livepilot/skills/livepilot-arrangement/SKILL.md +0 -155
- package/livepilot/skills/livepilot-composition-engine/SKILL.md +0 -107
- package/livepilot/skills/livepilot-composition-engine/references/form-patterns.md +0 -97
- package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +0 -102
- package/livepilot/skills/livepilot-core/SKILL.md +0 -184
- package/livepilot/skills/livepilot-core/references/ableton-workflow-patterns.md +0 -831
- package/livepilot/skills/livepilot-core/references/automation-atlas.md +0 -272
- package/livepilot/skills/livepilot-core/references/device-atlas/00-index.md +0 -110
- package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +0 -687
- package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +0 -753
- package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +0 -525
- package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +0 -402
- package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +0 -963
- package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +0 -874
- package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +0 -571
- package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +0 -714
- package/livepilot/skills/livepilot-core/references/device-atlas/synths-native.md +0 -953
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +0 -34
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +0 -204
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +0 -173
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +0 -211
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +0 -188
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +0 -162
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +0 -229
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +0 -243
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +0 -352
- package/livepilot/skills/livepilot-core/references/memory-guide.md +0 -107
- package/livepilot/skills/livepilot-core/references/midi-recipes.md +0 -402
- package/livepilot/skills/livepilot-core/references/mixing-patterns.md +0 -578
- package/livepilot/skills/livepilot-core/references/overview.md +0 -290
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +0 -724
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +0 -140
- package/livepilot/skills/livepilot-core/references/sound-design.md +0 -393
- package/livepilot/skills/livepilot-devices/SKILL.md +0 -169
- package/livepilot/skills/livepilot-evaluation/SKILL.md +0 -156
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +0 -118
- package/livepilot/skills/livepilot-evaluation/references/evaluation-contracts.md +0 -121
- package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +0 -110
- package/livepilot/skills/livepilot-mix-engine/SKILL.md +0 -123
- package/livepilot/skills/livepilot-mix-engine/references/mix-critics.md +0 -143
- package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +0 -105
- package/livepilot/skills/livepilot-mixing/SKILL.md +0 -157
- package/livepilot/skills/livepilot-notes/SKILL.md +0 -130
- package/livepilot/skills/livepilot-performance-engine/SKILL.md +0 -122
- package/livepilot/skills/livepilot-performance-engine/references/performance-safety.md +0 -98
- package/livepilot/skills/livepilot-release/SKILL.md +0 -130
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +0 -105
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +0 -87
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +0 -51
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +0 -131
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +0 -168
- package/livepilot/skills/livepilot-sound-design-engine/references/patch-model.md +0 -119
- package/livepilot/skills/livepilot-sound-design-engine/references/sound-design-critics.md +0 -118
- package/livepilot/skills/livepilot-wonder/SKILL.md +0 -79
- package/m4l_device/LivePilot_Analyzer.maxpat +0 -2705
- package/manifest.json +0 -91
- package/mcp_server/splice_client/protos/app_pb2.pyi +0 -1153
- package/scripts/generate_tool_catalog.py +0 -131
- package/scripts/sync_metadata.py +0 -132
|
@@ -92,7 +92,7 @@ REDUCE_REPETITION = SemanticMove(
|
|
|
92
92
|
],
|
|
93
93
|
verification_plan=[
|
|
94
94
|
{"tool": "get_track_meters", "check": "all tracks still producing audio", "backend": "remote_command"},
|
|
95
|
-
{"tool": "capture_audio", "check": "LRA > 2 LU (dynamic range should increase)", "backend": "
|
|
95
|
+
{"tool": "capture_audio", "check": "LRA > 2 LU (dynamic range should increase)", "backend": "bridge_command"},
|
|
96
96
|
],
|
|
97
97
|
)
|
|
98
98
|
|
|
@@ -24,6 +24,10 @@ class SemanticMove:
|
|
|
24
24
|
plan_template: list = field(default_factory=list) # [{tool, params, description}] — static metadata, NOT runtime truth
|
|
25
25
|
verification_plan: list = field(default_factory=list) # [{tool, check}]
|
|
26
26
|
confidence: float = 0.7
|
|
27
|
+
# analytical_only: move is intentionally metadata-only — no compiler is
|
|
28
|
+
# expected. Surfaces in discovery/wonder_mode but never executes. Set this
|
|
29
|
+
# to True for moves that are deliberate "hints" rather than orphan-by-bug.
|
|
30
|
+
analytical_only: bool = False
|
|
27
31
|
|
|
28
32
|
def to_dict(self) -> dict:
|
|
29
33
|
return {
|
|
@@ -36,6 +40,7 @@ class SemanticMove:
|
|
|
36
40
|
"required_capabilities": self.required_capabilities,
|
|
37
41
|
"plan_template_steps": len(self.plan_template),
|
|
38
42
|
"confidence": self.confidence,
|
|
43
|
+
"analytical_only": self.analytical_only,
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
def to_full_dict(self) -> dict:
|
|
@@ -14,9 +14,11 @@ from . import resolvers
|
|
|
14
14
|
def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
15
15
|
"""Compile 'add_warmth': volume boost + reverb send for perceived warmth.
|
|
16
16
|
|
|
17
|
-
SAFETY: Never
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
SAFETY: Never target device parameters by raw index. Ableton's parameter
|
|
18
|
+
index 0 is "Device On" on every device, so set_device_parameter(idx=0)
|
|
19
|
+
with any fractional value rounds to 0 and DISABLES the device. Use sends
|
|
20
|
+
and volume for warmth; device-param automation is only safe once the
|
|
21
|
+
resolver can look parameters up by name.
|
|
20
22
|
"""
|
|
21
23
|
steps = []
|
|
22
24
|
descriptions = []
|
|
@@ -31,24 +33,6 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
31
33
|
idx = t["index"]
|
|
32
34
|
name = t["name"]
|
|
33
35
|
|
|
34
|
-
# Try to find a Saturator on the track (safe device adjustment)
|
|
35
|
-
saturator = resolvers.find_device_on_track(kernel, idx, "Saturator")
|
|
36
|
-
if saturator:
|
|
37
|
-
steps.append(CompiledStep(
|
|
38
|
-
tool="set_device_parameter",
|
|
39
|
-
params={
|
|
40
|
-
"track_index": idx,
|
|
41
|
-
"device_index": saturator["device_index"],
|
|
42
|
-
"parameter_index": 0,
|
|
43
|
-
"value": 0.3,
|
|
44
|
-
},
|
|
45
|
-
description=f"Gentle Saturator drive on {name}",
|
|
46
|
-
))
|
|
47
|
-
descriptions.append(f"Saturate {name}")
|
|
48
|
-
else:
|
|
49
|
-
# No Saturator found — use volume + send instead of risky device params
|
|
50
|
-
warnings.append(f"No Saturator on {name} — using volume+reverb for warmth")
|
|
51
|
-
|
|
52
36
|
# Boost volume slightly for perceived warmth
|
|
53
37
|
steps.append(CompiledStep(
|
|
54
38
|
tool="set_track_volume",
|
|
@@ -84,32 +68,22 @@ def _compile_add_warmth(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
84
68
|
|
|
85
69
|
|
|
86
70
|
def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
87
|
-
"""Compile 'add_texture':
|
|
71
|
+
"""Compile 'add_texture': delay send for spatial texture.
|
|
72
|
+
|
|
73
|
+
Device-parameter automation (perlin filter motion) was removed because it
|
|
74
|
+
targeted device_index=0, parameter_index=0 without a resolver check — that
|
|
75
|
+
hits "Device On" on every Ableton device and would silently disable the
|
|
76
|
+
first device. Re-enable once resolvers.find_device_parameter lands.
|
|
77
|
+
"""
|
|
88
78
|
steps = []
|
|
89
79
|
descriptions = []
|
|
80
|
+
warnings = []
|
|
90
81
|
|
|
91
82
|
targets = resolvers.find_tracks_by_role(kernel, ["pad", "chords", "lead"])
|
|
92
83
|
|
|
93
84
|
for t in targets[:1]:
|
|
94
85
|
idx = t["index"]
|
|
95
86
|
name = t["name"]
|
|
96
|
-
steps.append(CompiledStep(
|
|
97
|
-
tool="apply_automation_shape",
|
|
98
|
-
params={
|
|
99
|
-
"track_index": idx,
|
|
100
|
-
"clip_index": 0,
|
|
101
|
-
"parameter_type": "device",
|
|
102
|
-
"device_index": 0,
|
|
103
|
-
"parameter_index": 0,
|
|
104
|
-
"curve_type": "perlin",
|
|
105
|
-
"center": 0.4,
|
|
106
|
-
"amplitude": 0.2,
|
|
107
|
-
"duration": 8,
|
|
108
|
-
"density": 16,
|
|
109
|
-
},
|
|
110
|
-
description=f"Perlin filter motion on {name} for organic texture",
|
|
111
|
-
))
|
|
112
|
-
descriptions.append(f"Perlin filter on {name}")
|
|
113
87
|
|
|
114
88
|
# Add delay send
|
|
115
89
|
steps.append(CompiledStep(
|
|
@@ -119,6 +93,9 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
119
93
|
))
|
|
120
94
|
descriptions.append(f"Delay texture on {name}")
|
|
121
95
|
|
|
96
|
+
if not targets:
|
|
97
|
+
warnings.append("No pad/chords/lead tracks — texture needs a melodic bed")
|
|
98
|
+
|
|
122
99
|
steps.append(CompiledStep(
|
|
123
100
|
tool="get_track_meters",
|
|
124
101
|
params={"include_stereo": True},
|
|
@@ -132,14 +109,17 @@ def _compile_add_texture(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
132
109
|
risk_level="medium",
|
|
133
110
|
summary="; ".join(descriptions) if descriptions else "No tracks for texture",
|
|
134
111
|
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
112
|
+
warnings=warnings,
|
|
135
113
|
)
|
|
136
114
|
|
|
137
115
|
|
|
138
116
|
def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
139
117
|
"""Compile 'shape_transients': push drum volume for punch, adjust sends.
|
|
140
118
|
|
|
141
|
-
SAFETY: Never
|
|
142
|
-
|
|
119
|
+
SAFETY: Never target device parameters by raw index. Index 0 on every
|
|
120
|
+
Ableton device is "Device On" — writing 0.2 rounds to 0 and disables the
|
|
121
|
+
device. Punch is achieved via volume + send shaping; Compressor attack
|
|
122
|
+
automation is only safe once the resolver can look parameters up by name.
|
|
143
123
|
"""
|
|
144
124
|
steps = []
|
|
145
125
|
descriptions = []
|
|
@@ -158,24 +138,7 @@ def _compile_shape_transients(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
|
158
138
|
idx = dt["index"]
|
|
159
139
|
name = dt["name"]
|
|
160
140
|
|
|
161
|
-
#
|
|
162
|
-
compressor = resolvers.find_device_on_track(kernel, idx, "Compressor")
|
|
163
|
-
if compressor:
|
|
164
|
-
steps.append(CompiledStep(
|
|
165
|
-
tool="set_device_parameter",
|
|
166
|
-
params={
|
|
167
|
-
"track_index": idx,
|
|
168
|
-
"device_index": compressor["device_index"],
|
|
169
|
-
"parameter_index": 0,
|
|
170
|
-
"value": 0.2,
|
|
171
|
-
},
|
|
172
|
-
description=f"Faster Compressor attack on {name} for snap",
|
|
173
|
-
))
|
|
174
|
-
descriptions.append(f"Shape {name} compressor")
|
|
175
|
-
else:
|
|
176
|
-
warnings.append(f"No Compressor on {name} — using volume push for punch")
|
|
177
|
-
|
|
178
|
-
# Push volume for transient punch regardless
|
|
141
|
+
# Push volume for transient punch
|
|
179
142
|
steps.append(CompiledStep(
|
|
180
143
|
tool="set_track_volume",
|
|
181
144
|
params={"track_index": idx, "volume": 0.75},
|
|
@@ -229,10 +229,21 @@ async def apply_semantic_move(
|
|
|
229
229
|
# explore mode — execute through the async router
|
|
230
230
|
from ..runtime.execution_router import execute_plan_steps_async
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
# Propagate the optional backend annotation through to the router so a
|
|
233
|
+
# compiler that's certain about a step's backend (e.g. bridge_command for
|
|
234
|
+
# capture_audio) can short-circuit classify_step(). Steps without backend
|
|
235
|
+
# fall back to the classifier as before.
|
|
236
|
+
def _step_to_dict(step):
|
|
237
|
+
d = {
|
|
238
|
+
"tool": step.tool,
|
|
239
|
+
"params": step.params,
|
|
240
|
+
"description": step.description,
|
|
241
|
+
}
|
|
242
|
+
if getattr(step, "backend", None):
|
|
243
|
+
d["backend"] = step.backend
|
|
244
|
+
return d
|
|
245
|
+
|
|
246
|
+
step_dicts = [_step_to_dict(step) for step in plan.steps]
|
|
236
247
|
bridge = ctx.lifespan_context.get("m4l")
|
|
237
248
|
mcp_registry = ctx.lifespan_context.get("mcp_dispatch", {})
|
|
238
249
|
exec_results = await execute_plan_steps_async(
|
|
@@ -8,31 +8,20 @@ from . import resolvers
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> CompiledPlan:
|
|
11
|
-
"""Compile 'increase_forward_motion':
|
|
11
|
+
"""Compile 'increase_forward_motion': rhythm push + reverb wash.
|
|
12
|
+
|
|
13
|
+
Device-parameter automation (rising filter sweep) was removed: targeting
|
|
14
|
+
device_index=0, parameter_index=0 without a resolver lookup hits "Device
|
|
15
|
+
On" on every Ableton device and would disable the first effect. Re-enable
|
|
16
|
+
once resolvers.find_device_parameter can locate a filter cutoff by name.
|
|
17
|
+
"""
|
|
12
18
|
steps = []
|
|
13
19
|
descriptions = []
|
|
20
|
+
warnings = []
|
|
14
21
|
|
|
15
22
|
melodic = resolvers.find_tracks_by_role(kernel, ["chords", "lead", "pad"])
|
|
16
23
|
drums = resolvers.find_tracks_by_role(kernel, ["drums", "percussion"])
|
|
17
24
|
|
|
18
|
-
for mt in melodic[:1]:
|
|
19
|
-
steps.append(CompiledStep(
|
|
20
|
-
tool="apply_automation_shape",
|
|
21
|
-
params={
|
|
22
|
-
"track_index": mt["index"],
|
|
23
|
-
"clip_index": 0,
|
|
24
|
-
"parameter_type": "device",
|
|
25
|
-
"device_index": 0,
|
|
26
|
-
"parameter_index": 0,
|
|
27
|
-
"curve_type": "exponential",
|
|
28
|
-
"start": 0.2,
|
|
29
|
-
"end": 0.7,
|
|
30
|
-
"duration": 4,
|
|
31
|
-
},
|
|
32
|
-
description=f"Rising filter sweep on {mt['name']} over 4 bars",
|
|
33
|
-
))
|
|
34
|
-
descriptions.append(f"Rising filter on {mt['name']}")
|
|
35
|
-
|
|
36
25
|
for dt in drums[:1]:
|
|
37
26
|
steps.append(CompiledStep(
|
|
38
27
|
tool="set_track_volume",
|
|
@@ -49,6 +38,9 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
|
|
|
49
38
|
))
|
|
50
39
|
descriptions.append(f"Reverb wash on {mt['name']}")
|
|
51
40
|
|
|
41
|
+
if not drums and not melodic:
|
|
42
|
+
warnings.append("No drum or melodic tracks — cannot build forward motion")
|
|
43
|
+
|
|
52
44
|
steps.append(CompiledStep(
|
|
53
45
|
tool="get_track_meters",
|
|
54
46
|
params={"include_stereo": True},
|
|
@@ -62,6 +54,7 @@ def _compile_increase_forward_motion(move: SemanticMove, kernel: dict) -> Compil
|
|
|
62
54
|
risk_level="low",
|
|
63
55
|
summary="; ".join(descriptions) if descriptions else "No melodic tracks for motion",
|
|
64
56
|
requires_approval=(kernel.get("mode", "improve") != "explore"),
|
|
57
|
+
warnings=warnings,
|
|
65
58
|
)
|
|
66
59
|
|
|
67
60
|
|
package/mcp_server/server.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from contextlib import asynccontextmanager
|
|
4
4
|
import asyncio
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
import subprocess
|
|
7
8
|
|
|
@@ -10,6 +11,12 @@ from fastmcp import FastMCP, Context # noqa: F401
|
|
|
10
11
|
from .connection import AbletonConnection
|
|
11
12
|
from .m4l_bridge import SpectralCache, SpectralReceiver, M4LBridge
|
|
12
13
|
|
|
14
|
+
# Logger must be defined before any function uses it — several module-level
|
|
15
|
+
# helpers below (e.g. _master_has_livepilot_analyzer) call logger.debug on
|
|
16
|
+
# the import-time code path, so defining logger later raised NameError when
|
|
17
|
+
# those helpers fired from a tool module's module-level init.
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
13
20
|
|
|
14
21
|
def _identify_port_holder(port: int) -> str | None:
|
|
15
22
|
"""Identify which process holds the given UDP port (for logging only).
|
|
@@ -33,13 +40,76 @@ def _identify_port_holder(port: int) -> str | None:
|
|
|
33
40
|
text=True, timeout=2,
|
|
34
41
|
).strip()
|
|
35
42
|
return f"{pid} ({cmdline[:60]})"
|
|
36
|
-
except (subprocess.CalledProcessError,
|
|
43
|
+
except (subprocess.CalledProcessError,
|
|
44
|
+
subprocess.TimeoutExpired,
|
|
45
|
+
FileNotFoundError):
|
|
37
46
|
return str(pid)
|
|
38
47
|
return None
|
|
39
|
-
except (subprocess.CalledProcessError,
|
|
48
|
+
except (subprocess.CalledProcessError,
|
|
49
|
+
subprocess.TimeoutExpired,
|
|
50
|
+
FileNotFoundError,
|
|
51
|
+
ValueError):
|
|
52
|
+
# TimeoutExpired catches the busy-system case where lsof exceeds
|
|
53
|
+
# the 3-second budget; we treat it as "can't identify" and return
|
|
54
|
+
# None so startup never stalls for slow host diagnostics.
|
|
40
55
|
return None
|
|
41
56
|
|
|
42
57
|
|
|
58
|
+
def _check_remote_script_version(ableton: AbletonConnection) -> None:
|
|
59
|
+
"""BUG-A1: detect stale Remote Script installs at startup.
|
|
60
|
+
|
|
61
|
+
The installed Remote Script is loaded by Ableton at its own launch time
|
|
62
|
+
and cached in Python's module system — source-tree edits don't take
|
|
63
|
+
effect until the user reinstalls + restarts Live. When the installed
|
|
64
|
+
copy lags behind the MCP-server source, commands added after the install
|
|
65
|
+
date (e.g. ``insert_device`` in v1.10.6) return "Unknown command type".
|
|
66
|
+
|
|
67
|
+
This check pings the Remote Script, compares its reported version to
|
|
68
|
+
the MCP server version, and logs a loud warning on mismatch. We don't
|
|
69
|
+
abort — the server should still work for whatever handlers the older
|
|
70
|
+
Remote Script does support — but we make the drift visible.
|
|
71
|
+
"""
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from . import __version__ as mcp_version
|
|
76
|
+
except ImportError:
|
|
77
|
+
mcp_version = "unknown"
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
pong = ableton.send_command("ping")
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
import logging as _logging
|
|
83
|
+
_logging.getLogger(__name__).debug(
|
|
84
|
+
"Remote Script version check failed: %s", exc,
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
if not isinstance(pong, dict):
|
|
89
|
+
return
|
|
90
|
+
rs_version = pong.get("remote_script_version")
|
|
91
|
+
if rs_version is None:
|
|
92
|
+
# Remote Script is old enough that it doesn't even embed its version
|
|
93
|
+
# in ping responses — definitely stale.
|
|
94
|
+
msg = (
|
|
95
|
+
"LivePilot: Remote Script is out of date (pre-version-handshake). "
|
|
96
|
+
"Run 'npx livepilot --install' and restart Ableton Live to fix "
|
|
97
|
+
"'Unknown command type' errors for newer tools (insert_device, "
|
|
98
|
+
"set_clip_pitch, etc)."
|
|
99
|
+
)
|
|
100
|
+
print(msg, file=sys.stderr)
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
if str(rs_version) != str(mcp_version):
|
|
104
|
+
msg = (
|
|
105
|
+
f"LivePilot: Remote Script version {rs_version} does not match "
|
|
106
|
+
f"MCP server version {mcp_version}. Newer tools may fail with "
|
|
107
|
+
f"'Unknown command type'. Run 'npx livepilot --install' and "
|
|
108
|
+
f"restart Ableton Live to resync."
|
|
109
|
+
)
|
|
110
|
+
print(msg, file=sys.stderr)
|
|
111
|
+
|
|
112
|
+
|
|
43
113
|
def _master_has_livepilot_analyzer(ableton: AbletonConnection) -> bool:
|
|
44
114
|
"""Check whether the analyzer device is currently on the master track."""
|
|
45
115
|
try:
|
|
@@ -128,6 +198,9 @@ async def lifespan(server):
|
|
|
128
198
|
}
|
|
129
199
|
|
|
130
200
|
try:
|
|
201
|
+
# BUG-A1: detect stale Remote Script installs early so the user
|
|
202
|
+
# sees a clear message instead of cryptic "Unknown command type" errors.
|
|
203
|
+
_check_remote_script_version(ableton)
|
|
131
204
|
if bridge_state["transport"] is not None:
|
|
132
205
|
await _warm_analyzer_bridge(ableton, spectral)
|
|
133
206
|
yield {
|
|
@@ -198,9 +271,6 @@ from .device_forge import tools as device_forge_tools # noqa: F401, E40
|
|
|
198
271
|
from .sample_engine import tools as sample_engine_tools # noqa: F401, E402
|
|
199
272
|
from .atlas import tools as atlas_tools # noqa: F401, E402
|
|
200
273
|
from .composer import tools as composer_tools # noqa: F401, E402
|
|
201
|
-
import logging
|
|
202
|
-
|
|
203
|
-
logger = logging.getLogger(__name__)
|
|
204
274
|
|
|
205
275
|
# ---------------------------------------------------------------------------
|
|
206
276
|
# Schema coercion patch — accept strings for numeric parameters
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Thread-safe singleton helpers.
|
|
2
|
+
|
|
3
|
+
The server has several subsystems (atlas, corpus, sample-engine indexes)
|
|
4
|
+
that are loaded lazily into module-level globals via a check-then-set
|
|
5
|
+
pattern. Under FastMCP's async concurrency that pattern races: two
|
|
6
|
+
handlers can both observe ``None`` and both construct the (expensive)
|
|
7
|
+
object. Most of the time the GIL hides the race, but when it doesn't you
|
|
8
|
+
get redundant I/O and, worse, one thread's half-parsed state overwriting
|
|
9
|
+
the other's completed state.
|
|
10
|
+
|
|
11
|
+
This module provides a small helper that wraps a factory in a lock and
|
|
12
|
+
optionally tracks an on-disk mtime for cache invalidation. Use it in
|
|
13
|
+
place of hand-rolled ``_instance = None`` patterns.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from threading import Lock
|
|
19
|
+
from typing import Callable, TypeVar
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Singleton:
|
|
25
|
+
"""Lazy, thread-safe singleton with optional mtime-based reload.
|
|
26
|
+
|
|
27
|
+
Example:
|
|
28
|
+
atlas_holder = Singleton(_load_atlas)
|
|
29
|
+
|
|
30
|
+
def get_atlas():
|
|
31
|
+
return atlas_holder.get(reload_if_newer=atlas_path)
|
|
32
|
+
|
|
33
|
+
def on_atlas_rebuild():
|
|
34
|
+
atlas_holder.invalidate()
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, factory: Callable[[], T]):
|
|
38
|
+
self._factory = factory
|
|
39
|
+
self._instance: T | None = None
|
|
40
|
+
self._mtime: float | None = None
|
|
41
|
+
self._lock = Lock()
|
|
42
|
+
|
|
43
|
+
def get(self, *, reload_if_newer: Path | None = None) -> T:
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._instance is None:
|
|
46
|
+
self._instance = self._factory()
|
|
47
|
+
if reload_if_newer is not None:
|
|
48
|
+
try:
|
|
49
|
+
self._mtime = reload_if_newer.stat().st_mtime
|
|
50
|
+
except OSError:
|
|
51
|
+
self._mtime = None
|
|
52
|
+
return self._instance
|
|
53
|
+
|
|
54
|
+
if reload_if_newer is not None:
|
|
55
|
+
try:
|
|
56
|
+
current = reload_if_newer.stat().st_mtime
|
|
57
|
+
except OSError:
|
|
58
|
+
return self._instance
|
|
59
|
+
if self._mtime is None or current > self._mtime:
|
|
60
|
+
self._instance = self._factory()
|
|
61
|
+
self._mtime = current
|
|
62
|
+
return self._instance
|
|
63
|
+
|
|
64
|
+
def invalidate(self) -> None:
|
|
65
|
+
"""Discard the cached instance. Next .get() will re-run the factory."""
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._instance = None
|
|
68
|
+
self._mtime = None
|
|
@@ -50,6 +50,9 @@ class SessionStory:
|
|
|
50
50
|
"""The narrative of the current session."""
|
|
51
51
|
|
|
52
52
|
song_id: str = ""
|
|
53
|
+
# BUG-B16: link back to the SongBrain snapshot that generated the
|
|
54
|
+
# identity_summary so callers can tell which brain was used.
|
|
55
|
+
song_brain_id: str = ""
|
|
53
56
|
identity_summary: str = ""
|
|
54
57
|
what_changed_last: str = ""
|
|
55
58
|
what_still_feels_open: list[str] = field(default_factory=list)
|
|
@@ -60,6 +63,7 @@ class SessionStory:
|
|
|
60
63
|
def to_dict(self) -> dict:
|
|
61
64
|
return {
|
|
62
65
|
"song_id": self.song_id,
|
|
66
|
+
"song_brain_id": self.song_brain_id,
|
|
63
67
|
"identity_summary": self.identity_summary,
|
|
64
68
|
"what_changed_last": self.what_changed_last,
|
|
65
69
|
"what_still_feels_open": self.what_still_feels_open,
|
|
@@ -50,10 +50,23 @@ def reset_story() -> None:
|
|
|
50
50
|
def get_session_story(
|
|
51
51
|
song_brain: Optional[dict] = None,
|
|
52
52
|
) -> SessionStory:
|
|
53
|
-
"""Get the current session story with identity summary.
|
|
53
|
+
"""Get the current session story with identity summary.
|
|
54
|
+
|
|
55
|
+
BUG-B16: now also populates song_brain_id from the passed brain so
|
|
56
|
+
callers can tell which brain generated the identity_summary.
|
|
57
|
+
Previously the field was empty and users got a half-populated
|
|
58
|
+
response that read as "something's wrong" even though the partial
|
|
59
|
+
data was correct for a fresh session.
|
|
60
|
+
"""
|
|
54
61
|
song_brain = song_brain or {}
|
|
55
62
|
|
|
56
63
|
_story.identity_summary = song_brain.get("identity_core", "")
|
|
64
|
+
_story.song_brain_id = str(song_brain.get("brain_id", "") or "")
|
|
65
|
+
# Carry song_id through when present on the brain — fresh sessions
|
|
66
|
+
# leave this empty, which is documented below.
|
|
67
|
+
if not _story.song_id and song_brain.get("song_id"):
|
|
68
|
+
_story.song_id = str(song_brain.get("song_id"))
|
|
69
|
+
|
|
57
70
|
_story.threads = [t for t in _threads.values() if t.status == "open"]
|
|
58
71
|
_story.turns = _turns
|
|
59
72
|
_story.what_still_feels_open = [
|
|
@@ -127,6 +127,14 @@ def _infer_identity_core(
|
|
|
127
127
|
"""Infer the single strongest defining idea in the session.
|
|
128
128
|
|
|
129
129
|
Returns (description, confidence).
|
|
130
|
+
|
|
131
|
+
BUG-B10 fix: the old logic picked "Dominant texture: drums" at
|
|
132
|
+
confidence 0.5 for almost every session — because drum tracks
|
|
133
|
+
typically have the most notes. We now consider richer signals:
|
|
134
|
+
featured vocals, scene-name aesthetics, tempo+key context, and
|
|
135
|
+
single-instrument dominance. When multiple low-confidence signals
|
|
136
|
+
align (e.g. "dust" aesthetic + vocal hook + D minor key), we
|
|
137
|
+
combine them into a compound identity string.
|
|
130
138
|
"""
|
|
131
139
|
candidates: list[tuple[str, float]] = []
|
|
132
140
|
|
|
@@ -144,7 +152,7 @@ def _infer_identity_core(
|
|
|
144
152
|
if arc_type:
|
|
145
153
|
candidates.append((f"Emotional arc: {arc_type}", 0.6))
|
|
146
154
|
|
|
147
|
-
# From role graph — dominant texture
|
|
155
|
+
# From role graph — dominant texture (kept but gently deranked)
|
|
148
156
|
# role_graph format: {track_name: {index: int, role: str}}
|
|
149
157
|
if role_graph:
|
|
150
158
|
role_counts = Counter(
|
|
@@ -153,9 +161,15 @@ def _infer_identity_core(
|
|
|
153
161
|
if isinstance(info, dict)
|
|
154
162
|
)
|
|
155
163
|
role_counts.pop("unknown", None)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
# B10 fix: drums being the "dominant texture" is almost never
|
|
165
|
+
# what the song is ABOUT — it's just that drum tracks have the
|
|
166
|
+
# most notes. Skip drums/perc from this candidate stream.
|
|
167
|
+
_BORING_DOMINANT = {"drums", "percussion", "kick", "snare", "hat"}
|
|
168
|
+
for role, _ in role_counts.most_common(3):
|
|
169
|
+
if role.lower() in _BORING_DOMINANT:
|
|
170
|
+
continue
|
|
171
|
+
candidates.append((f"Dominant texture: {role}", 0.55))
|
|
172
|
+
break
|
|
159
173
|
|
|
160
174
|
# From track analysis — genre/style cues
|
|
161
175
|
track_names = [t.get("name", "").lower() for t in tracks]
|
|
@@ -163,12 +177,65 @@ def _infer_identity_core(
|
|
|
163
177
|
if genre_cues:
|
|
164
178
|
candidates.append((f"Style: {genre_cues}", 0.4))
|
|
165
179
|
|
|
180
|
+
# BUG-B10: featured instrument — a named vocal/pad/lead track with
|
|
181
|
+
# an explicit function is usually more identity-defining than
|
|
182
|
+
# "dominant texture: drums".
|
|
183
|
+
_FEATURED_TOKENS = (
|
|
184
|
+
("vocal", "vocal hook", 0.75),
|
|
185
|
+
("vox", "vocal hook", 0.72),
|
|
186
|
+
("pad", "pad-led atmosphere", 0.55),
|
|
187
|
+
("lead", "lead synth melody", 0.65),
|
|
188
|
+
("rhodes", "rhodes-keys texture", 0.60),
|
|
189
|
+
("piano", "piano-led harmony", 0.60),
|
|
190
|
+
("guitar", "guitar-led", 0.60),
|
|
191
|
+
("saxophone", "saxophone solo", 0.65),
|
|
192
|
+
("brass", "brass section", 0.55),
|
|
193
|
+
)
|
|
194
|
+
for name in track_names:
|
|
195
|
+
for token, label, conf in _FEATURED_TOKENS:
|
|
196
|
+
if token in name:
|
|
197
|
+
candidates.append((f"Featured element: {label}", conf))
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
# BUG-B10: scene-name aesthetic cues. A scene named "Intro Dust" /
|
|
201
|
+
# "Outro Dust" signals a deliberate dust/lo-fi aesthetic; "Sun Peak"
|
|
202
|
+
# / "Peak" signals a climax-oriented structure. Pull these from the
|
|
203
|
+
# composition-analysis section list if present.
|
|
204
|
+
_AESTHETIC_TOKENS = (
|
|
205
|
+
("dust", "dust-toned lo-fi"),
|
|
206
|
+
("sun", "warm/sun-peaked"),
|
|
207
|
+
("fog", "foggy/dreamy"),
|
|
208
|
+
("glass", "brittle/glass-like"),
|
|
209
|
+
("void", "void/ambient-spatial"),
|
|
210
|
+
("haze", "hazy/nostalgic"),
|
|
211
|
+
("bloom", "blooming/evolving"),
|
|
212
|
+
)
|
|
213
|
+
sections = composition.get("sections", []) or []
|
|
214
|
+
section_names = " ".join(
|
|
215
|
+
str(s.get("name", "") or s.get("label", "")).lower()
|
|
216
|
+
for s in sections
|
|
217
|
+
)
|
|
218
|
+
for token, label in _AESTHETIC_TOKENS:
|
|
219
|
+
if token in section_names:
|
|
220
|
+
candidates.append((f"Aesthetic: {label}", 0.55))
|
|
221
|
+
break
|
|
222
|
+
|
|
166
223
|
if not candidates:
|
|
167
224
|
# Fallback: describe by track count and tempo
|
|
168
225
|
return ("Emerging piece — identity not yet established", 0.2)
|
|
169
226
|
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
# BUG-B10: when no single candidate is confident (>0.6), blend the
|
|
228
|
+
# top 2 into a compound identity — captures "vocal hook + dust
|
|
229
|
+
# aesthetic" style identity rather than picking one weak signal.
|
|
230
|
+
candidates.sort(key=lambda c: c[1], reverse=True)
|
|
231
|
+
top = candidates[0]
|
|
232
|
+
if top[1] >= 0.6 or len(candidates) < 2:
|
|
233
|
+
return top
|
|
234
|
+
# Blend top 2
|
|
235
|
+
second = candidates[1]
|
|
236
|
+
blended_desc = f"{top[0]} + {second[0].lower()}"
|
|
237
|
+
blended_conf = min(0.85, (top[1] + second[1]) / 2 + 0.1)
|
|
238
|
+
return (blended_desc, blended_conf)
|
|
172
239
|
|
|
173
240
|
|
|
174
241
|
def _detect_genre_cues(track_names: list[str]) -> str:
|
|
@@ -260,6 +327,12 @@ def _detect_sacred_elements(
|
|
|
260
327
|
# ── Section purposes ──────────────────────────────────────────────
|
|
261
328
|
|
|
262
329
|
|
|
330
|
+
# Section intents that imply this is a "payoff" / arrival moment.
|
|
331
|
+
# Used by _infer_section_purposes to derive is_payoff consistently
|
|
332
|
+
# when composition returns an intent label without the explicit flag.
|
|
333
|
+
_PAYOFF_INTENTS = frozenset({"payoff", "drop", "chorus", "hook"})
|
|
334
|
+
|
|
335
|
+
|
|
263
336
|
def _infer_section_purposes(
|
|
264
337
|
scenes: list[dict],
|
|
265
338
|
composition: dict,
|
|
@@ -271,12 +344,27 @@ def _infer_section_purposes(
|
|
|
271
344
|
comp_sections = composition.get("sections", [])
|
|
272
345
|
if comp_sections:
|
|
273
346
|
for sec in comp_sections:
|
|
347
|
+
name = str(sec.get("name", ""))
|
|
348
|
+
# BUG-B12: skip empty placeholder sections that pollute the
|
|
349
|
+
# energy_arc and section_purposes list. A section with no name
|
|
350
|
+
# AND zero energy corresponds to an unnamed empty scene slot.
|
|
351
|
+
if not name.strip() and not sec.get("energy", 0):
|
|
352
|
+
continue
|
|
353
|
+
intent = sec.get("intent", sec.get("purpose", "")) or ""
|
|
354
|
+
# BUG-B11: derive is_payoff from the intent label when the
|
|
355
|
+
# explicit flag isn't set. Composition engine returns
|
|
356
|
+
# intent="drop"/"chorus"/"hook"/"payoff" — these all mean the
|
|
357
|
+
# section IS a payoff, so is_payoff must reflect that.
|
|
358
|
+
is_payoff = bool(
|
|
359
|
+
sec.get("is_payoff", False)
|
|
360
|
+
or intent.lower() in _PAYOFF_INTENTS
|
|
361
|
+
)
|
|
274
362
|
sections.append(SectionPurpose(
|
|
275
|
-
section_id=sec.get("id",
|
|
276
|
-
label=sec.get("label",
|
|
277
|
-
emotional_intent=
|
|
363
|
+
section_id=sec.get("id", name),
|
|
364
|
+
label=sec.get("label", name),
|
|
365
|
+
emotional_intent=intent,
|
|
278
366
|
energy_level=sec.get("energy", 0.5),
|
|
279
|
-
is_payoff=
|
|
367
|
+
is_payoff=is_payoff,
|
|
280
368
|
confidence=0.7,
|
|
281
369
|
))
|
|
282
370
|
return sections
|
|
@@ -284,6 +372,10 @@ def _infer_section_purposes(
|
|
|
284
372
|
# Fallback: infer from scene names
|
|
285
373
|
for i, scene in enumerate(scenes):
|
|
286
374
|
name = scene.get("name", f"Scene {i}")
|
|
375
|
+
# BUG-B12 (fallback path): skip empty scenes so they don't pollute
|
|
376
|
+
# the output even when no composition data is available.
|
|
377
|
+
if not str(name).strip():
|
|
378
|
+
continue
|
|
287
379
|
label, intent, energy, is_payoff = _classify_scene_name(name, i, len(scenes))
|
|
288
380
|
sections.append(SectionPurpose(
|
|
289
381
|
section_id=f"scene_{i}",
|
|
@@ -389,8 +481,14 @@ def _detect_open_questions(
|
|
|
389
481
|
))
|
|
390
482
|
|
|
391
483
|
# Missing sections (common gaps)
|
|
392
|
-
|
|
393
|
-
|
|
484
|
+
# BUG-B14: check substrings across labels AND emotional intents
|
|
485
|
+
# (case-insensitive) so scene names like "Intro Dust" or intent "intro"
|
|
486
|
+
# both satisfy the check. Exact-match on the label set missed those.
|
|
487
|
+
signal_text = " ".join(
|
|
488
|
+
f"{s.label} {s.emotional_intent}".lower() for s in sections
|
|
489
|
+
)
|
|
490
|
+
has_intro = any(kw in signal_text for kw in ("intro", "opening", "opener"))
|
|
491
|
+
if len(sections) > 3 and not has_intro:
|
|
394
492
|
questions.append(OpenQuestion(
|
|
395
493
|
question="No intro section — does the track need an opening?",
|
|
396
494
|
domain="arrangement",
|