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.
Files changed (105) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +51 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +7 -7
  6. package/bin/livepilot.js +32 -8
  7. package/installer/install.js +21 -2
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/agents/livepilot-producer/AGENT.md +243 -49
  11. package/livepilot/skills/livepilot-core/SKILL.md +81 -6
  12. package/livepilot/skills/livepilot-core/references/m4l-devices.md +2 -2
  13. package/livepilot/skills/livepilot-core/references/overview.md +3 -3
  14. package/livepilot/skills/livepilot-core/references/sound-design.md +3 -2
  15. package/livepilot/skills/livepilot-release/SKILL.md +13 -13
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +6 -3
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/curves.py +11 -3
  20. package/mcp_server/evaluation/__init__.py +1 -0
  21. package/mcp_server/evaluation/fabric.py +575 -0
  22. package/mcp_server/evaluation/feature_extractors.py +84 -0
  23. package/mcp_server/evaluation/policy.py +67 -0
  24. package/mcp_server/evaluation/tools.py +53 -0
  25. package/mcp_server/memory/__init__.py +11 -2
  26. package/mcp_server/memory/anti_memory.py +78 -0
  27. package/mcp_server/memory/promotion.py +94 -0
  28. package/mcp_server/memory/session_memory.py +108 -0
  29. package/mcp_server/memory/taste_memory.py +158 -0
  30. package/mcp_server/memory/technique_store.py +2 -1
  31. package/mcp_server/memory/tools.py +112 -0
  32. package/mcp_server/mix_engine/__init__.py +1 -0
  33. package/mcp_server/mix_engine/critics.py +299 -0
  34. package/mcp_server/mix_engine/models.py +152 -0
  35. package/mcp_server/mix_engine/planner.py +103 -0
  36. package/mcp_server/mix_engine/state_builder.py +316 -0
  37. package/mcp_server/mix_engine/tools.py +214 -0
  38. package/mcp_server/performance_engine/__init__.py +1 -0
  39. package/mcp_server/performance_engine/models.py +148 -0
  40. package/mcp_server/performance_engine/planner.py +267 -0
  41. package/mcp_server/performance_engine/safety.py +162 -0
  42. package/mcp_server/performance_engine/tools.py +183 -0
  43. package/mcp_server/project_brain/__init__.py +6 -0
  44. package/mcp_server/project_brain/arrangement_graph.py +64 -0
  45. package/mcp_server/project_brain/automation_graph.py +72 -0
  46. package/mcp_server/project_brain/builder.py +123 -0
  47. package/mcp_server/project_brain/capability_graph.py +64 -0
  48. package/mcp_server/project_brain/models.py +282 -0
  49. package/mcp_server/project_brain/refresh.py +80 -0
  50. package/mcp_server/project_brain/role_graph.py +103 -0
  51. package/mcp_server/project_brain/session_graph.py +51 -0
  52. package/mcp_server/project_brain/tools.py +144 -0
  53. package/mcp_server/reference_engine/__init__.py +1 -0
  54. package/mcp_server/reference_engine/gap_analyzer.py +239 -0
  55. package/mcp_server/reference_engine/models.py +105 -0
  56. package/mcp_server/reference_engine/profile_builder.py +149 -0
  57. package/mcp_server/reference_engine/tactic_router.py +117 -0
  58. package/mcp_server/reference_engine/tools.py +235 -0
  59. package/mcp_server/runtime/__init__.py +1 -0
  60. package/mcp_server/runtime/action_ledger.py +117 -0
  61. package/mcp_server/runtime/action_ledger_models.py +84 -0
  62. package/mcp_server/runtime/action_tools.py +57 -0
  63. package/mcp_server/runtime/capability_state.py +218 -0
  64. package/mcp_server/runtime/safety_kernel.py +339 -0
  65. package/mcp_server/runtime/safety_tools.py +42 -0
  66. package/mcp_server/runtime/tools.py +64 -0
  67. package/mcp_server/server.py +23 -1
  68. package/mcp_server/sound_design/__init__.py +1 -0
  69. package/mcp_server/sound_design/critics.py +297 -0
  70. package/mcp_server/sound_design/models.py +147 -0
  71. package/mcp_server/sound_design/planner.py +104 -0
  72. package/mcp_server/sound_design/tools.py +297 -0
  73. package/mcp_server/tools/_agent_os_engine.py +947 -0
  74. package/mcp_server/tools/_composition_engine.py +1530 -0
  75. package/mcp_server/tools/_conductor.py +199 -0
  76. package/mcp_server/tools/_conductor_budgets.py +222 -0
  77. package/mcp_server/tools/_evaluation_contracts.py +91 -0
  78. package/mcp_server/tools/_form_engine.py +416 -0
  79. package/mcp_server/tools/_motif_engine.py +351 -0
  80. package/mcp_server/tools/_planner_engine.py +516 -0
  81. package/mcp_server/tools/_research_engine.py +542 -0
  82. package/mcp_server/tools/_research_provider.py +185 -0
  83. package/mcp_server/tools/_snapshot_normalizer.py +49 -0
  84. package/mcp_server/tools/agent_os.py +440 -0
  85. package/mcp_server/tools/analyzer.py +18 -0
  86. package/mcp_server/tools/automation.py +25 -10
  87. package/mcp_server/tools/composition.py +563 -0
  88. package/mcp_server/tools/motif.py +104 -0
  89. package/mcp_server/tools/planner.py +144 -0
  90. package/mcp_server/tools/research.py +223 -0
  91. package/mcp_server/tools/tracks.py +18 -3
  92. package/mcp_server/tools/transport.py +10 -2
  93. package/mcp_server/transition_engine/__init__.py +6 -0
  94. package/mcp_server/transition_engine/archetypes.py +167 -0
  95. package/mcp_server/transition_engine/critics.py +340 -0
  96. package/mcp_server/transition_engine/models.py +90 -0
  97. package/mcp_server/transition_engine/tools.py +291 -0
  98. package/mcp_server/translation_engine/__init__.py +5 -0
  99. package/mcp_server/translation_engine/critics.py +297 -0
  100. package/mcp_server/translation_engine/models.py +27 -0
  101. package/mcp_server/translation_engine/tools.py +74 -0
  102. package/package.json +2 -2
  103. package/remote_script/LivePilot/__init__.py +1 -1
  104. package/remote_script/LivePilot/arrangement.py +12 -2
  105. package/requirements.txt +1 -1
@@ -0,0 +1,351 @@
1
+ """Motif Engine — pattern detection and transformation for musical motifs.
2
+
3
+ Detects recurring melodic and rhythmic patterns across clips, scores them
4
+ for salience and fatigue risk, and provides transformation operations
5
+ (inversion, augmentation, register shift, fragmentation).
6
+
7
+ Zero external dependencies beyond stdlib.
8
+ Design: spec at docs/COMPOSITION_ENGINE_V1.md, sections 7.5, 10.4, 11.3.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections import Counter
14
+ from dataclasses import asdict, dataclass, field
15
+ from typing import Optional
16
+
17
+
18
+ # ── Motif Data Structures ─────────────────────────────────────────────
19
+
20
+ @dataclass
21
+ class MotifUnit:
22
+ """A recurring musical pattern detected across clips."""
23
+ motif_id: str
24
+ kind: str # "melodic", "rhythmic", "intervallic"
25
+ intervals: list[int] # relative intervals (semitones between consecutive notes)
26
+ rhythm: list[float] # relative durations (ratios)
27
+ representative_pitches: list[int] # first occurrence's actual pitches
28
+ occurrences: list[dict] = field(default_factory=list) # [{track, clip, start_bar}]
29
+ salience: float = 0.0 # 0-1, how distinctive/memorable
30
+ fatigue_risk: float = 0.0 # 0-1, risk of overuse
31
+ suggested_developments: list[str] = field(default_factory=list)
32
+
33
+ def to_dict(self) -> dict:
34
+ return asdict(self)
35
+
36
+
37
+ # ── Pattern Extraction ────────────────────────────────────────────────
38
+
39
+ def _extract_intervals(notes: list[dict]) -> list[int]:
40
+ """Extract pitch intervals between consecutive notes (sorted by start_time)."""
41
+ if len(notes) < 2:
42
+ return []
43
+ sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
44
+ return [
45
+ sorted_notes[i + 1].get("pitch", 0) - sorted_notes[i].get("pitch", 0)
46
+ for i in range(len(sorted_notes) - 1)
47
+ ]
48
+
49
+
50
+ def _extract_rhythm(notes: list[dict]) -> list[float]:
51
+ """Extract rhythm pattern as duration ratios (normalized to first note)."""
52
+ if not notes:
53
+ return []
54
+ sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
55
+ durations = [n.get("duration", 0.5) for n in sorted_notes]
56
+ base = durations[0] if durations[0] > 0 else 0.5
57
+ return [round(d / base, 2) for d in durations]
58
+
59
+
60
+ def _find_recurring_subsequences(
61
+ intervals: list[int],
62
+ min_length: int = 3,
63
+ max_length: int = 8,
64
+ ) -> list[tuple[tuple[int, ...], list[int]]]:
65
+ """Find recurring interval subsequences and their start positions.
66
+
67
+ Returns list of (pattern_tuple, [start_indices]).
68
+ """
69
+ if len(intervals) < min_length:
70
+ return []
71
+
72
+ pattern_positions: dict[tuple[int, ...], list[int]] = {}
73
+
74
+ for length in range(min_length, min(max_length + 1, len(intervals) + 1)):
75
+ for start in range(len(intervals) - length + 1):
76
+ pattern = tuple(intervals[start:start + length])
77
+ pattern_positions.setdefault(pattern, []).append(start)
78
+
79
+ # Filter to patterns that occur at least twice
80
+ return [
81
+ (pattern, positions)
82
+ for pattern, positions in pattern_positions.items()
83
+ if len(positions) >= 2
84
+ ]
85
+
86
+
87
+ def _score_salience(pattern: tuple[int, ...], occurrence_count: int, total_notes: int) -> float:
88
+ """Score how memorable/distinctive a pattern is.
89
+
90
+ Higher salience for: longer patterns, more variety, moderate occurrence count.
91
+ """
92
+ length_score = min(1.0, len(pattern) / 8.0)
93
+
94
+ # Interval variety (unique intervals / total intervals)
95
+ unique_intervals = len(set(pattern))
96
+ variety_score = unique_intervals / max(len(pattern), 1)
97
+
98
+ # Occurrence: too few = obscure, too many = boring
99
+ occurrence_ratio = occurrence_count / max(total_notes / len(pattern), 1)
100
+ occurrence_score = min(1.0, occurrence_ratio * 2) * (1.0 - min(1.0, occurrence_ratio * 0.5))
101
+
102
+ return round(min(1.0, (length_score * 0.3 + variety_score * 0.4 + occurrence_score * 0.3)), 3)
103
+
104
+
105
+ def _score_fatigue(occurrence_count: int, total_bars: int) -> float:
106
+ """Score risk of listener fatigue from repetition.
107
+
108
+ Higher fatigue for: high occurrence count relative to total length.
109
+ """
110
+ if total_bars <= 0:
111
+ return 0.0
112
+ density = occurrence_count / total_bars
113
+ return round(min(1.0, density * 2), 3)
114
+
115
+
116
+ def _suggest_developments(motif: MotifUnit) -> list[str]:
117
+ """Suggest musical transformations based on motif properties."""
118
+ suggestions = []
119
+
120
+ if motif.fatigue_risk > 0.5:
121
+ suggestions.append("rhythmic_variation")
122
+ suggestions.append("register_shift")
123
+
124
+ if len(motif.intervals) >= 4:
125
+ suggestions.append("fragmentation")
126
+ suggestions.append("inversion")
127
+
128
+ if all(abs(i) <= 2 for i in motif.intervals):
129
+ suggestions.append("register_shift_up") # Stepwise motion → try a leap
130
+
131
+ if any(abs(i) >= 5 for i in motif.intervals):
132
+ suggestions.append("augmentation") # Has leaps → try slowing down
133
+
134
+ if motif.salience > 0.6:
135
+ suggestions.append("answer_phrase")
136
+ suggestions.append("orchestral_reassignment")
137
+
138
+ if not suggestions:
139
+ suggestions.append("register_shift")
140
+
141
+ return suggestions
142
+
143
+
144
+ # ── Motif Detection ───────────────────────────────────────────────────
145
+
146
+ def detect_motifs(
147
+ notes_by_track: dict[int, list[dict]],
148
+ total_bars: int = 32,
149
+ min_pattern_length: int = 3,
150
+ max_pattern_length: int = 8,
151
+ ) -> list[MotifUnit]:
152
+ """Detect recurring musical patterns across all tracks.
153
+
154
+ notes_by_track: {track_index: [note dicts with pitch, start_time, duration]}
155
+ total_bars: approximate total length for fatigue scoring
156
+ Returns: list of MotifUnit sorted by salience (most memorable first)
157
+ """
158
+ # Collect all intervals per track
159
+ all_patterns: dict[tuple[int, ...], list[dict]] = {}
160
+ total_note_count = 0
161
+
162
+ for track_idx, notes in notes_by_track.items():
163
+ if not notes:
164
+ continue
165
+ total_note_count += len(notes)
166
+ intervals = _extract_intervals(notes)
167
+
168
+ # Find recurring subsequences in this track
169
+ recurring = _find_recurring_subsequences(
170
+ intervals, min_pattern_length, max_pattern_length,
171
+ )
172
+
173
+ for pattern, positions in recurring:
174
+ if pattern not in all_patterns:
175
+ all_patterns[pattern] = []
176
+ sorted_notes = sorted(notes, key=lambda n: n.get("start_time", 0))
177
+ for pos in positions:
178
+ start_time = sorted_notes[pos].get("start_time", 0) if pos < len(sorted_notes) else 0
179
+ all_patterns[pattern].append({
180
+ "track": track_idx,
181
+ "start_position": pos,
182
+ "start_time": start_time,
183
+ })
184
+
185
+ # Also check for cross-track patterns
186
+ all_intervals_flat: list[tuple[int, list[int], int]] = []
187
+ for track_idx, notes in notes_by_track.items():
188
+ intervals = _extract_intervals(notes)
189
+ if intervals:
190
+ all_intervals_flat.append((track_idx, intervals, len(notes)))
191
+
192
+ # Build motif objects
193
+ motifs = []
194
+ seen_patterns: set[tuple[int, ...]] = set()
195
+
196
+ for pattern, occurrences in sorted(all_patterns.items(), key=lambda x: -len(x[1])):
197
+ # Skip sub-patterns of already-found patterns
198
+ if any(pattern != seen and _is_subsequence(pattern, seen) for seen in seen_patterns):
199
+ continue
200
+
201
+ salience = _score_salience(pattern, len(occurrences), total_note_count)
202
+ fatigue = _score_fatigue(len(occurrences), total_bars)
203
+
204
+ # Get representative pitches from first occurrence
205
+ first_occ = occurrences[0] if occurrences else {}
206
+ first_track = first_occ.get("track", 0)
207
+ first_pos = first_occ.get("start_position", 0)
208
+ rep_pitches = []
209
+ if first_track in notes_by_track:
210
+ sorted_notes = sorted(notes_by_track[first_track],
211
+ key=lambda n: n.get("start_time", 0))
212
+ rep_pitches = [
213
+ sorted_notes[first_pos + j].get("pitch", 60)
214
+ for j in range(min(len(pattern) + 1, len(sorted_notes) - first_pos))
215
+ ]
216
+
217
+ motif = MotifUnit(
218
+ motif_id=f"motif_{len(motifs):03d}",
219
+ kind="melodic" if any(abs(i) > 0 for i in pattern) else "rhythmic",
220
+ intervals=list(pattern),
221
+ rhythm=[], # TODO: rhythm detection in Phase 3
222
+ representative_pitches=rep_pitches,
223
+ occurrences=occurrences,
224
+ salience=salience,
225
+ fatigue_risk=fatigue,
226
+ )
227
+ motif.suggested_developments = _suggest_developments(motif)
228
+ motifs.append(motif)
229
+ seen_patterns.add(pattern)
230
+
231
+ if len(motifs) >= 10:
232
+ break # Cap at 10 most significant motifs
233
+
234
+ # Sort by salience
235
+ motifs.sort(key=lambda m: -m.salience)
236
+ return motifs
237
+
238
+
239
+ def _is_subsequence(short: tuple, long: tuple) -> bool:
240
+ """Check if short is a contiguous subsequence of long."""
241
+ if len(short) >= len(long):
242
+ return False
243
+ for i in range(len(long) - len(short) + 1):
244
+ if long[i:i + len(short)] == short:
245
+ return True
246
+ return False
247
+
248
+
249
+ # ── Motif Transformations ─────────────────────────────────────────────
250
+
251
+ def transform_motif(
252
+ motif: MotifUnit,
253
+ transformation: str,
254
+ reference_pitch: int = 60,
255
+ ) -> list[dict]:
256
+ """Apply a musical transformation to a motif, returning new notes.
257
+
258
+ transformation: "inversion", "augmentation", "diminution",
259
+ "fragmentation", "register_shift_up", "register_shift_down",
260
+ "retrograde"
261
+ reference_pitch: base pitch for the output (default: C4=60)
262
+ Returns: list of note dicts ready for add_notes
263
+ """
264
+ if not motif.representative_pitches:
265
+ return []
266
+
267
+ pitches = motif.representative_pitches
268
+ intervals = motif.intervals
269
+
270
+ if transformation == "inversion":
271
+ # Flip intervals: up becomes down
272
+ new_intervals = [-i for i in intervals]
273
+ return _intervals_to_notes(new_intervals, reference_pitch)
274
+
275
+ elif transformation == "retrograde":
276
+ # Reverse the interval sequence
277
+ new_intervals = list(reversed(intervals))
278
+ return _intervals_to_notes(new_intervals, reference_pitch)
279
+
280
+ elif transformation == "augmentation":
281
+ # Double the duration of each note
282
+ return _intervals_to_notes(intervals, reference_pitch, duration_multiplier=2.0)
283
+
284
+ elif transformation == "diminution":
285
+ # Halve the duration
286
+ return _intervals_to_notes(intervals, reference_pitch, duration_multiplier=0.5)
287
+
288
+ elif transformation == "fragmentation":
289
+ # Take only the first half of the motif
290
+ half = max(1, len(intervals) // 2)
291
+ return _intervals_to_notes(intervals[:half], reference_pitch)
292
+
293
+ elif transformation == "register_shift_up":
294
+ # Transpose up an octave
295
+ return _intervals_to_notes(intervals, reference_pitch + 12)
296
+
297
+ elif transformation == "register_shift_down":
298
+ # Transpose down an octave
299
+ return _intervals_to_notes(intervals, reference_pitch - 12)
300
+
301
+ elif transformation == "orchestral_reassignment":
302
+ # Redistribute across a wider register — odd notes up, even notes down
303
+ # Creates an interleaved texture from a single-voice motif
304
+ notes = _intervals_to_notes(intervals, reference_pitch)
305
+ for i, note in enumerate(notes):
306
+ if i % 2 == 0:
307
+ note["pitch"] = max(0, min(127, note["pitch"] + 7)) # Up a fifth
308
+ else:
309
+ note["pitch"] = max(0, min(127, note["pitch"] - 5)) # Down a fourth
310
+ note["velocity"] = max(40, min(127, note["velocity"] + (10 if i % 2 == 0 else -10)))
311
+ return notes
312
+
313
+ else:
314
+ raise ValueError(
315
+ f"Unknown transformation '{transformation}'. Valid: "
316
+ "inversion, retrograde, augmentation, diminution, fragmentation, "
317
+ "register_shift_up, register_shift_down, orchestral_reassignment"
318
+ )
319
+
320
+
321
+ def _intervals_to_notes(
322
+ intervals: list[int],
323
+ start_pitch: int = 60,
324
+ duration_multiplier: float = 1.0,
325
+ base_duration: float = 0.5,
326
+ base_velocity: int = 80,
327
+ ) -> list[dict]:
328
+ """Convert interval sequence to note dicts."""
329
+ notes = []
330
+ current_pitch = start_pitch
331
+ current_time = 0.0
332
+ duration = base_duration * duration_multiplier
333
+
334
+ notes.append({
335
+ "pitch": current_pitch,
336
+ "start_time": current_time,
337
+ "duration": duration,
338
+ "velocity": base_velocity,
339
+ })
340
+
341
+ for interval in intervals:
342
+ current_pitch += interval
343
+ current_time += duration
344
+ notes.append({
345
+ "pitch": max(0, min(127, current_pitch)),
346
+ "start_time": round(current_time, 4),
347
+ "duration": duration,
348
+ "velocity": base_velocity,
349
+ })
350
+
351
+ return notes