livepilot 1.9.13 → 1.9.15
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/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +51 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +7 -7
- package/bin/livepilot.js +32 -8
- package/installer/install.js +21 -2
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
- package/livepilot/skills/livepilot-core/SKILL.md +81 -6
- package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
- package/livepilot/skills/livepilot-core/references/overview.md +3 -3
- package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
- package/livepilot/skills/livepilot-release/SKILL.md +13 -13
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +6 -3
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/curves.py +11 -3
- package/mcp_server/evaluation/__init__.py +1 -0
- package/mcp_server/evaluation/fabric.py +575 -0
- package/mcp_server/evaluation/feature_extractors.py +84 -0
- package/mcp_server/evaluation/policy.py +67 -0
- package/mcp_server/evaluation/tools.py +53 -0
- package/mcp_server/memory/__init__.py +11 -2
- package/mcp_server/memory/anti_memory.py +78 -0
- package/mcp_server/memory/promotion.py +94 -0
- package/mcp_server/memory/session_memory.py +108 -0
- package/mcp_server/memory/taste_memory.py +158 -0
- package/mcp_server/memory/technique_store.py +2 -1
- package/mcp_server/memory/tools.py +112 -0
- package/mcp_server/mix_engine/__init__.py +1 -0
- package/mcp_server/mix_engine/critics.py +299 -0
- package/mcp_server/mix_engine/models.py +152 -0
- package/mcp_server/mix_engine/planner.py +103 -0
- package/mcp_server/mix_engine/state_builder.py +316 -0
- package/mcp_server/mix_engine/tools.py +214 -0
- package/mcp_server/performance_engine/__init__.py +1 -0
- package/mcp_server/performance_engine/models.py +148 -0
- package/mcp_server/performance_engine/planner.py +267 -0
- package/mcp_server/performance_engine/safety.py +162 -0
- package/mcp_server/performance_engine/tools.py +183 -0
- package/mcp_server/project_brain/__init__.py +6 -0
- package/mcp_server/project_brain/arrangement_graph.py +64 -0
- package/mcp_server/project_brain/automation_graph.py +72 -0
- package/mcp_server/project_brain/builder.py +123 -0
- package/mcp_server/project_brain/capability_graph.py +64 -0
- package/mcp_server/project_brain/models.py +282 -0
- package/mcp_server/project_brain/refresh.py +80 -0
- package/mcp_server/project_brain/role_graph.py +103 -0
- package/mcp_server/project_brain/session_graph.py +51 -0
- package/mcp_server/project_brain/tools.py +144 -0
- package/mcp_server/reference_engine/__init__.py +1 -0
- package/mcp_server/reference_engine/gap_analyzer.py +239 -0
- package/mcp_server/reference_engine/models.py +105 -0
- package/mcp_server/reference_engine/profile_builder.py +149 -0
- package/mcp_server/reference_engine/tactic_router.py +117 -0
- package/mcp_server/reference_engine/tools.py +235 -0
- package/mcp_server/runtime/__init__.py +1 -0
- package/mcp_server/runtime/action_ledger.py +117 -0
- package/mcp_server/runtime/action_ledger_models.py +84 -0
- package/mcp_server/runtime/action_tools.py +57 -0
- package/mcp_server/runtime/capability_state.py +218 -0
- package/mcp_server/runtime/safety_kernel.py +339 -0
- package/mcp_server/runtime/safety_tools.py +42 -0
- package/mcp_server/runtime/tools.py +64 -0
- package/mcp_server/server.py +23 -1
- package/mcp_server/sound_design/__init__.py +1 -0
- package/mcp_server/sound_design/critics.py +297 -0
- package/mcp_server/sound_design/models.py +147 -0
- package/mcp_server/sound_design/planner.py +104 -0
- package/mcp_server/sound_design/tools.py +297 -0
- package/mcp_server/tools/_agent_os_engine.py +947 -0
- package/mcp_server/tools/_composition_engine.py +1530 -0
- package/mcp_server/tools/_conductor.py +199 -0
- package/mcp_server/tools/_conductor_budgets.py +222 -0
- package/mcp_server/tools/_evaluation_contracts.py +91 -0
- package/mcp_server/tools/_form_engine.py +416 -0
- package/mcp_server/tools/_motif_engine.py +351 -0
- package/mcp_server/tools/_planner_engine.py +516 -0
- package/mcp_server/tools/_research_engine.py +542 -0
- package/mcp_server/tools/_research_provider.py +185 -0
- package/mcp_server/tools/_snapshot_normalizer.py +49 -0
- package/mcp_server/tools/agent_os.py +440 -0
- package/mcp_server/tools/analyzer.py +18 -0
- package/mcp_server/tools/automation.py +25 -10
- package/mcp_server/tools/composition.py +563 -0
- package/mcp_server/tools/motif.py +104 -0
- package/mcp_server/tools/planner.py +144 -0
- package/mcp_server/tools/research.py +223 -0
- package/mcp_server/tools/tracks.py +18 -3
- package/mcp_server/tools/transport.py +10 -2
- package/mcp_server/transition_engine/__init__.py +6 -0
- package/mcp_server/transition_engine/archetypes.py +167 -0
- package/mcp_server/transition_engine/critics.py +340 -0
- package/mcp_server/transition_engine/models.py +90 -0
- package/mcp_server/transition_engine/tools.py +291 -0
- package/mcp_server/translation_engine/__init__.py +5 -0
- package/mcp_server/translation_engine/critics.py +297 -0
- package/mcp_server/translation_engine/models.py +27 -0
- package/mcp_server/translation_engine/tools.py +74 -0
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/arrangement.py +12 -2
- package/requirements.txt +1 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Form Engine — radical structural transformations for arrangements.
|
|
2
|
+
|
|
3
|
+
Reorder sections, expand loops, compress verbose arrangements, insert bridges,
|
|
4
|
+
and propose new section graphs from transformation commands.
|
|
5
|
+
|
|
6
|
+
Zero external dependencies beyond stdlib.
|
|
7
|
+
|
|
8
|
+
Design: spec at docs/specs/2026-04-08-phase2-4-roadmap.md, Round 4 (4.4).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from copy import deepcopy
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ._composition_engine import SectionNode, SectionType
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── Transformation Types ─────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
VALID_TRANSFORMATIONS = frozenset({
|
|
23
|
+
"insert_bridge_before_final_chorus",
|
|
24
|
+
"swap_verse_positions",
|
|
25
|
+
"extend_section",
|
|
26
|
+
"compress_section",
|
|
27
|
+
"insert_breakdown",
|
|
28
|
+
"duplicate_section",
|
|
29
|
+
"remove_section",
|
|
30
|
+
"reverse_section_order",
|
|
31
|
+
"split_section",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FormTransformation:
|
|
37
|
+
"""A proposed structural transformation with before/after section graphs."""
|
|
38
|
+
transformation: str
|
|
39
|
+
target_section_index: Optional[int]
|
|
40
|
+
before_sections: list[SectionNode]
|
|
41
|
+
after_sections: list[SectionNode]
|
|
42
|
+
description: str
|
|
43
|
+
bar_delta: int # how many bars added (positive) or removed (negative)
|
|
44
|
+
|
|
45
|
+
def to_dict(self) -> dict:
|
|
46
|
+
return {
|
|
47
|
+
"transformation": self.transformation,
|
|
48
|
+
"target_section_index": self.target_section_index,
|
|
49
|
+
"before_section_count": len(self.before_sections),
|
|
50
|
+
"after_section_count": len(self.after_sections),
|
|
51
|
+
"description": self.description,
|
|
52
|
+
"bar_delta": self.bar_delta,
|
|
53
|
+
"after_sections": [s.to_dict() for s in self.after_sections],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ── Core Transform Functions ─────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def transform_section_order(
|
|
60
|
+
sections: list[SectionNode],
|
|
61
|
+
transformation: str,
|
|
62
|
+
target_index: Optional[int] = None,
|
|
63
|
+
bars: int = 8,
|
|
64
|
+
) -> FormTransformation:
|
|
65
|
+
"""Apply a structural transformation to the section graph.
|
|
66
|
+
|
|
67
|
+
sections: current section graph
|
|
68
|
+
transformation: one of VALID_TRANSFORMATIONS
|
|
69
|
+
target_index: which section to transform (for targeted operations)
|
|
70
|
+
bars: how many bars (for extend/compress/insert)
|
|
71
|
+
|
|
72
|
+
Returns: FormTransformation with the proposed new section graph.
|
|
73
|
+
"""
|
|
74
|
+
if transformation not in VALID_TRANSFORMATIONS:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Unknown transformation '{transformation}'. "
|
|
77
|
+
f"Valid: {sorted(VALID_TRANSFORMATIONS)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not sections:
|
|
81
|
+
raise ValueError("Cannot transform empty section graph")
|
|
82
|
+
|
|
83
|
+
# Deep copy to avoid mutating originals
|
|
84
|
+
original = sections
|
|
85
|
+
new_sections = deepcopy(sections)
|
|
86
|
+
|
|
87
|
+
if transformation == "insert_bridge_before_final_chorus":
|
|
88
|
+
return _insert_bridge_before_final_chorus(original, new_sections, bars)
|
|
89
|
+
|
|
90
|
+
elif transformation == "swap_verse_positions":
|
|
91
|
+
return _swap_verse_positions(original, new_sections)
|
|
92
|
+
|
|
93
|
+
elif transformation == "extend_section":
|
|
94
|
+
if target_index is None:
|
|
95
|
+
raise ValueError("extend_section requires target_index")
|
|
96
|
+
return _extend_section(original, new_sections, target_index, bars)
|
|
97
|
+
|
|
98
|
+
elif transformation == "compress_section":
|
|
99
|
+
if target_index is None:
|
|
100
|
+
raise ValueError("compress_section requires target_index")
|
|
101
|
+
return _compress_section(original, new_sections, target_index, bars)
|
|
102
|
+
|
|
103
|
+
elif transformation == "insert_breakdown":
|
|
104
|
+
if target_index is None:
|
|
105
|
+
target_index = _find_best_breakdown_position(new_sections)
|
|
106
|
+
return _insert_breakdown(original, new_sections, target_index, bars)
|
|
107
|
+
|
|
108
|
+
elif transformation == "duplicate_section":
|
|
109
|
+
if target_index is None:
|
|
110
|
+
raise ValueError("duplicate_section requires target_index")
|
|
111
|
+
return _duplicate_section(original, new_sections, target_index)
|
|
112
|
+
|
|
113
|
+
elif transformation == "remove_section":
|
|
114
|
+
if target_index is None:
|
|
115
|
+
raise ValueError("remove_section requires target_index")
|
|
116
|
+
return _remove_section(original, new_sections, target_index)
|
|
117
|
+
|
|
118
|
+
elif transformation == "reverse_section_order":
|
|
119
|
+
return _reverse_section_order(original, new_sections)
|
|
120
|
+
|
|
121
|
+
elif transformation == "split_section":
|
|
122
|
+
if target_index is None:
|
|
123
|
+
raise ValueError("split_section requires target_index")
|
|
124
|
+
return _split_section(original, new_sections, target_index)
|
|
125
|
+
|
|
126
|
+
# Should not reach here due to validation above
|
|
127
|
+
raise ValueError(f"Unhandled transformation: {transformation}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _reindex_sections(sections: list[SectionNode]) -> list[SectionNode]:
|
|
131
|
+
"""Re-assign section_ids and adjust bar positions to be contiguous."""
|
|
132
|
+
current_bar = 0
|
|
133
|
+
for i, section in enumerate(sections):
|
|
134
|
+
length = section.end_bar - section.start_bar
|
|
135
|
+
section.section_id = f"sec_{i:02d}"
|
|
136
|
+
section.start_bar = current_bar
|
|
137
|
+
section.end_bar = current_bar + length
|
|
138
|
+
current_bar += length
|
|
139
|
+
return sections
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _total_bars(sections: list[SectionNode]) -> int:
|
|
143
|
+
return sections[-1].end_bar if sections else 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ── Individual Transformations ───────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def _insert_bridge_before_final_chorus(
|
|
149
|
+
original: list[SectionNode],
|
|
150
|
+
sections: list[SectionNode],
|
|
151
|
+
bridge_bars: int,
|
|
152
|
+
) -> FormTransformation:
|
|
153
|
+
"""Insert a bridge section before the last chorus/drop."""
|
|
154
|
+
# Find the last chorus or drop
|
|
155
|
+
last_climax_idx = None
|
|
156
|
+
for i in range(len(sections) - 1, -1, -1):
|
|
157
|
+
if sections[i].section_type in (SectionType.CHORUS, SectionType.DROP):
|
|
158
|
+
last_climax_idx = i
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
if last_climax_idx is None:
|
|
162
|
+
# No chorus/drop found — insert before the last section
|
|
163
|
+
last_climax_idx = len(sections) - 1
|
|
164
|
+
|
|
165
|
+
# Create bridge section
|
|
166
|
+
insert_bar = sections[last_climax_idx].start_bar
|
|
167
|
+
bridge = SectionNode(
|
|
168
|
+
section_id="bridge_new",
|
|
169
|
+
start_bar=insert_bar,
|
|
170
|
+
end_bar=insert_bar + bridge_bars,
|
|
171
|
+
section_type=SectionType.BRIDGE,
|
|
172
|
+
confidence=0.9,
|
|
173
|
+
energy=0.4,
|
|
174
|
+
density=0.3,
|
|
175
|
+
tracks_active=sections[last_climax_idx].tracks_active[:3], # Sparse
|
|
176
|
+
name="Bridge",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
sections.insert(last_climax_idx, bridge)
|
|
180
|
+
sections = _reindex_sections(sections)
|
|
181
|
+
|
|
182
|
+
return FormTransformation(
|
|
183
|
+
transformation="insert_bridge_before_final_chorus",
|
|
184
|
+
target_section_index=last_climax_idx,
|
|
185
|
+
before_sections=original,
|
|
186
|
+
after_sections=sections,
|
|
187
|
+
description=f"Inserted {bridge_bars}-bar bridge before final climax",
|
|
188
|
+
bar_delta=bridge_bars,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _swap_verse_positions(
|
|
193
|
+
original: list[SectionNode],
|
|
194
|
+
sections: list[SectionNode],
|
|
195
|
+
) -> FormTransformation:
|
|
196
|
+
"""Swap the first two verse sections for variety."""
|
|
197
|
+
verse_indices = [i for i, s in enumerate(sections) if s.section_type == SectionType.VERSE]
|
|
198
|
+
|
|
199
|
+
if len(verse_indices) < 2:
|
|
200
|
+
return FormTransformation(
|
|
201
|
+
transformation="swap_verse_positions",
|
|
202
|
+
target_section_index=None,
|
|
203
|
+
before_sections=original,
|
|
204
|
+
after_sections=sections,
|
|
205
|
+
description="Not enough verses to swap (need at least 2)",
|
|
206
|
+
bar_delta=0,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
i, j = verse_indices[0], verse_indices[1]
|
|
210
|
+
sections[i], sections[j] = sections[j], sections[i]
|
|
211
|
+
sections = _reindex_sections(sections)
|
|
212
|
+
|
|
213
|
+
return FormTransformation(
|
|
214
|
+
transformation="swap_verse_positions",
|
|
215
|
+
target_section_index=verse_indices[0],
|
|
216
|
+
before_sections=original,
|
|
217
|
+
after_sections=sections,
|
|
218
|
+
description=f"Swapped verses at positions {i} and {j}",
|
|
219
|
+
bar_delta=0,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extend_section(
|
|
224
|
+
original: list[SectionNode],
|
|
225
|
+
sections: list[SectionNode],
|
|
226
|
+
target_index: int,
|
|
227
|
+
bars: int,
|
|
228
|
+
) -> FormTransformation:
|
|
229
|
+
if target_index < 0 or target_index >= len(sections):
|
|
230
|
+
raise ValueError(f"target_index {target_index} out of range")
|
|
231
|
+
|
|
232
|
+
sections[target_index].end_bar += bars
|
|
233
|
+
sections = _reindex_sections(sections)
|
|
234
|
+
|
|
235
|
+
return FormTransformation(
|
|
236
|
+
transformation="extend_section",
|
|
237
|
+
target_section_index=target_index,
|
|
238
|
+
before_sections=original,
|
|
239
|
+
after_sections=sections,
|
|
240
|
+
description=f"Extended section {target_index} by {bars} bars",
|
|
241
|
+
bar_delta=bars,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _compress_section(
|
|
246
|
+
original: list[SectionNode],
|
|
247
|
+
sections: list[SectionNode],
|
|
248
|
+
target_index: int,
|
|
249
|
+
bars: int,
|
|
250
|
+
) -> FormTransformation:
|
|
251
|
+
if target_index < 0 or target_index >= len(sections):
|
|
252
|
+
raise ValueError(f"target_index {target_index} out of range")
|
|
253
|
+
|
|
254
|
+
current_length = sections[target_index].end_bar - sections[target_index].start_bar
|
|
255
|
+
new_length = max(4, current_length - bars) # Minimum 4 bars
|
|
256
|
+
actual_reduction = current_length - new_length
|
|
257
|
+
sections[target_index].end_bar = sections[target_index].start_bar + new_length
|
|
258
|
+
sections = _reindex_sections(sections)
|
|
259
|
+
|
|
260
|
+
return FormTransformation(
|
|
261
|
+
transformation="compress_section",
|
|
262
|
+
target_section_index=target_index,
|
|
263
|
+
before_sections=original,
|
|
264
|
+
after_sections=sections,
|
|
265
|
+
description=f"Compressed section {target_index} by {actual_reduction} bars",
|
|
266
|
+
bar_delta=-actual_reduction,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _find_best_breakdown_position(sections: list[SectionNode]) -> int:
|
|
271
|
+
"""Find the best position for a breakdown — after the highest energy section."""
|
|
272
|
+
if not sections:
|
|
273
|
+
return 0
|
|
274
|
+
max_energy_idx = max(range(len(sections)), key=lambda i: sections[i].energy)
|
|
275
|
+
return min(max_energy_idx + 1, len(sections))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _insert_breakdown(
|
|
279
|
+
original: list[SectionNode],
|
|
280
|
+
sections: list[SectionNode],
|
|
281
|
+
position: int,
|
|
282
|
+
bars: int,
|
|
283
|
+
) -> FormTransformation:
|
|
284
|
+
position = min(position, len(sections))
|
|
285
|
+
insert_bar = sections[position].start_bar if position < len(sections) else _total_bars(sections)
|
|
286
|
+
|
|
287
|
+
# Breakdown inherits tracks from surrounding section but reduces
|
|
288
|
+
ref_tracks = sections[min(position, len(sections) - 1)].tracks_active
|
|
289
|
+
breakdown = SectionNode(
|
|
290
|
+
section_id="bd_new",
|
|
291
|
+
start_bar=insert_bar,
|
|
292
|
+
end_bar=insert_bar + bars,
|
|
293
|
+
section_type=SectionType.BREAKDOWN,
|
|
294
|
+
confidence=0.9,
|
|
295
|
+
energy=0.25,
|
|
296
|
+
density=0.2,
|
|
297
|
+
tracks_active=ref_tracks[:2], # Very sparse
|
|
298
|
+
name="Breakdown",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
sections.insert(position, breakdown)
|
|
302
|
+
sections = _reindex_sections(sections)
|
|
303
|
+
|
|
304
|
+
return FormTransformation(
|
|
305
|
+
transformation="insert_breakdown",
|
|
306
|
+
target_section_index=position,
|
|
307
|
+
before_sections=original,
|
|
308
|
+
after_sections=sections,
|
|
309
|
+
description=f"Inserted {bars}-bar breakdown at position {position}",
|
|
310
|
+
bar_delta=bars,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _duplicate_section(
|
|
315
|
+
original: list[SectionNode],
|
|
316
|
+
sections: list[SectionNode],
|
|
317
|
+
target_index: int,
|
|
318
|
+
) -> FormTransformation:
|
|
319
|
+
if target_index < 0 or target_index >= len(sections):
|
|
320
|
+
raise ValueError(f"target_index {target_index} out of range")
|
|
321
|
+
|
|
322
|
+
source = sections[target_index]
|
|
323
|
+
length = source.end_bar - source.start_bar
|
|
324
|
+
duplicate = deepcopy(source)
|
|
325
|
+
duplicate.name = f"{source.name} (repeat)" if source.name else ""
|
|
326
|
+
|
|
327
|
+
sections.insert(target_index + 1, duplicate)
|
|
328
|
+
sections = _reindex_sections(sections)
|
|
329
|
+
|
|
330
|
+
return FormTransformation(
|
|
331
|
+
transformation="duplicate_section",
|
|
332
|
+
target_section_index=target_index,
|
|
333
|
+
before_sections=original,
|
|
334
|
+
after_sections=sections,
|
|
335
|
+
description=f"Duplicated section {target_index} ({length} bars)",
|
|
336
|
+
bar_delta=length,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _remove_section(
|
|
341
|
+
original: list[SectionNode],
|
|
342
|
+
sections: list[SectionNode],
|
|
343
|
+
target_index: int,
|
|
344
|
+
) -> FormTransformation:
|
|
345
|
+
if target_index < 0 or target_index >= len(sections):
|
|
346
|
+
raise ValueError(f"target_index {target_index} out of range")
|
|
347
|
+
if len(sections) <= 1:
|
|
348
|
+
raise ValueError("Cannot remove the only section")
|
|
349
|
+
|
|
350
|
+
removed = sections.pop(target_index)
|
|
351
|
+
removed_bars = removed.end_bar - removed.start_bar
|
|
352
|
+
sections = _reindex_sections(sections)
|
|
353
|
+
|
|
354
|
+
return FormTransformation(
|
|
355
|
+
transformation="remove_section",
|
|
356
|
+
target_section_index=target_index,
|
|
357
|
+
before_sections=original,
|
|
358
|
+
after_sections=sections,
|
|
359
|
+
description=f"Removed section {target_index} ({removed_bars} bars)",
|
|
360
|
+
bar_delta=-removed_bars,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _reverse_section_order(
|
|
365
|
+
original: list[SectionNode],
|
|
366
|
+
sections: list[SectionNode],
|
|
367
|
+
) -> FormTransformation:
|
|
368
|
+
sections.reverse()
|
|
369
|
+
sections = _reindex_sections(sections)
|
|
370
|
+
|
|
371
|
+
return FormTransformation(
|
|
372
|
+
transformation="reverse_section_order",
|
|
373
|
+
target_section_index=None,
|
|
374
|
+
before_sections=original,
|
|
375
|
+
after_sections=sections,
|
|
376
|
+
description=f"Reversed order of {len(sections)} sections",
|
|
377
|
+
bar_delta=0,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _split_section(
|
|
382
|
+
original: list[SectionNode],
|
|
383
|
+
sections: list[SectionNode],
|
|
384
|
+
target_index: int,
|
|
385
|
+
) -> FormTransformation:
|
|
386
|
+
if target_index < 0 or target_index >= len(sections):
|
|
387
|
+
raise ValueError(f"target_index {target_index} out of range")
|
|
388
|
+
|
|
389
|
+
source = sections[target_index]
|
|
390
|
+
length = source.end_bar - source.start_bar
|
|
391
|
+
if length < 8:
|
|
392
|
+
raise ValueError(f"Section too short to split ({length} bars, minimum 8)")
|
|
393
|
+
|
|
394
|
+
midpoint = source.start_bar + length // 2
|
|
395
|
+
|
|
396
|
+
# First half keeps original type
|
|
397
|
+
first_half = deepcopy(source)
|
|
398
|
+
first_half.end_bar = midpoint
|
|
399
|
+
|
|
400
|
+
# Second half
|
|
401
|
+
second_half = deepcopy(source)
|
|
402
|
+
second_half.start_bar = midpoint
|
|
403
|
+
second_half.name = f"{source.name} B" if source.name else ""
|
|
404
|
+
|
|
405
|
+
sections[target_index] = first_half
|
|
406
|
+
sections.insert(target_index + 1, second_half)
|
|
407
|
+
sections = _reindex_sections(sections)
|
|
408
|
+
|
|
409
|
+
return FormTransformation(
|
|
410
|
+
transformation="split_section",
|
|
411
|
+
target_section_index=target_index,
|
|
412
|
+
before_sections=original,
|
|
413
|
+
after_sections=sections,
|
|
414
|
+
description=f"Split section {target_index} into two {length // 2}-bar halves",
|
|
415
|
+
bar_delta=0,
|
|
416
|
+
)
|