livepilot 1.9.22 → 1.9.24

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 (118) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.mcpbignore +40 -0
  3. package/AGENTS.md +3 -3
  4. package/CHANGELOG.md +84 -0
  5. package/CONTRIBUTING.md +1 -1
  6. package/README.md +141 -72
  7. package/bin/livepilot.js +135 -0
  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 +13 -0
  11. package/livepilot/commands/arrange.md +42 -23
  12. package/livepilot/commands/mix.md +34 -19
  13. package/livepilot/commands/perform.md +31 -19
  14. package/livepilot/commands/sounddesign.md +38 -25
  15. package/livepilot/skills/livepilot-arrangement/SKILL.md +2 -1
  16. package/livepilot/skills/livepilot-composition-engine/references/transition-archetypes.md +2 -2
  17. package/livepilot/skills/livepilot-core/SKILL.md +60 -4
  18. package/livepilot/skills/livepilot-core/references/device-atlas/distortion-and-character.md +11 -11
  19. package/livepilot/skills/livepilot-core/references/device-atlas/drums-and-percussion.md +25 -25
  20. package/livepilot/skills/livepilot-core/references/device-atlas/dynamics-and-punch.md +21 -21
  21. package/livepilot/skills/livepilot-core/references/device-atlas/eq-and-filtering.md +13 -13
  22. package/livepilot/skills/livepilot-core/references/device-atlas/midi-tools.md +13 -13
  23. package/livepilot/skills/livepilot-core/references/device-atlas/movement-and-modulation.md +5 -5
  24. package/livepilot/skills/livepilot-core/references/device-atlas/space-and-depth.md +16 -16
  25. package/livepilot/skills/livepilot-core/references/device-atlas/spectral-and-weird.md +40 -40
  26. package/livepilot/skills/livepilot-core/references/m4l-devices.md +3 -3
  27. package/livepilot/skills/livepilot-core/references/overview.md +4 -4
  28. package/livepilot/skills/livepilot-evaluation/SKILL.md +12 -8
  29. package/livepilot/skills/livepilot-evaluation/references/memory-promotion.md +2 -2
  30. package/livepilot/skills/livepilot-mix-engine/SKILL.md +1 -1
  31. package/livepilot/skills/livepilot-mix-engine/references/mix-moves.md +2 -2
  32. package/livepilot/skills/livepilot-mixing/SKILL.md +3 -1
  33. package/livepilot/skills/livepilot-notes/SKILL.md +2 -1
  34. package/livepilot/skills/livepilot-release/SKILL.md +29 -15
  35. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +2 -2
  36. package/livepilot/skills/livepilot-wonder/SKILL.md +62 -0
  37. package/livepilot.mcpb +0 -0
  38. package/manifest.json +91 -0
  39. package/mcp_server/__init__.py +1 -1
  40. package/mcp_server/creative_constraints/__init__.py +6 -0
  41. package/mcp_server/creative_constraints/engine.py +277 -0
  42. package/mcp_server/creative_constraints/models.py +75 -0
  43. package/mcp_server/creative_constraints/tools.py +341 -0
  44. package/mcp_server/experiment/__init__.py +6 -0
  45. package/mcp_server/experiment/engine.py +213 -0
  46. package/mcp_server/experiment/models.py +120 -0
  47. package/mcp_server/experiment/tools.py +263 -0
  48. package/mcp_server/hook_hunter/__init__.py +5 -0
  49. package/mcp_server/hook_hunter/analyzer.py +365 -0
  50. package/mcp_server/hook_hunter/models.py +58 -0
  51. package/mcp_server/hook_hunter/tools.py +588 -0
  52. package/mcp_server/memory/taste_graph.py +328 -0
  53. package/mcp_server/memory/tools.py +99 -0
  54. package/mcp_server/mix_engine/critics.py +2 -2
  55. package/mcp_server/mix_engine/models.py +1 -1
  56. package/mcp_server/mix_engine/state_builder.py +2 -2
  57. package/mcp_server/musical_intelligence/__init__.py +8 -0
  58. package/mcp_server/musical_intelligence/detectors.py +434 -0
  59. package/mcp_server/musical_intelligence/phrase_critic.py +163 -0
  60. package/mcp_server/musical_intelligence/tools.py +224 -0
  61. package/mcp_server/persistence/__init__.py +1 -0
  62. package/mcp_server/persistence/base_store.py +82 -0
  63. package/mcp_server/persistence/project_store.py +106 -0
  64. package/mcp_server/persistence/taste_store.py +122 -0
  65. package/mcp_server/preview_studio/__init__.py +5 -0
  66. package/mcp_server/preview_studio/engine.py +280 -0
  67. package/mcp_server/preview_studio/models.py +74 -0
  68. package/mcp_server/preview_studio/tools.py +466 -0
  69. package/mcp_server/runtime/capability.py +66 -0
  70. package/mcp_server/runtime/capability_probe.py +118 -0
  71. package/mcp_server/runtime/execution_router.py +139 -0
  72. package/mcp_server/runtime/remote_commands.py +82 -0
  73. package/mcp_server/runtime/session_kernel.py +96 -0
  74. package/mcp_server/runtime/tools.py +90 -1
  75. package/mcp_server/semantic_moves/__init__.py +13 -0
  76. package/mcp_server/semantic_moves/compiler.py +116 -0
  77. package/mcp_server/semantic_moves/mix_compilers.py +291 -0
  78. package/mcp_server/semantic_moves/mix_moves.py +157 -0
  79. package/mcp_server/semantic_moves/models.py +46 -0
  80. package/mcp_server/semantic_moves/performance_compilers.py +208 -0
  81. package/mcp_server/semantic_moves/performance_moves.py +81 -0
  82. package/mcp_server/semantic_moves/registry.py +32 -0
  83. package/mcp_server/semantic_moves/resolvers.py +126 -0
  84. package/mcp_server/semantic_moves/sound_design_compilers.py +266 -0
  85. package/mcp_server/semantic_moves/sound_design_moves.py +78 -0
  86. package/mcp_server/semantic_moves/tools.py +205 -0
  87. package/mcp_server/semantic_moves/transition_compilers.py +222 -0
  88. package/mcp_server/semantic_moves/transition_moves.py +76 -0
  89. package/mcp_server/server.py +10 -0
  90. package/mcp_server/services/__init__.py +1 -0
  91. package/mcp_server/services/motif_service.py +67 -0
  92. package/mcp_server/session_continuity/__init__.py +6 -0
  93. package/mcp_server/session_continuity/models.py +86 -0
  94. package/mcp_server/session_continuity/tools.py +230 -0
  95. package/mcp_server/session_continuity/tracker.py +263 -0
  96. package/mcp_server/song_brain/__init__.py +6 -0
  97. package/mcp_server/song_brain/builder.py +504 -0
  98. package/mcp_server/song_brain/models.py +136 -0
  99. package/mcp_server/song_brain/tools.py +312 -0
  100. package/mcp_server/stuckness_detector/__init__.py +5 -0
  101. package/mcp_server/stuckness_detector/detector.py +400 -0
  102. package/mcp_server/stuckness_detector/models.py +66 -0
  103. package/mcp_server/stuckness_detector/tools.py +195 -0
  104. package/mcp_server/tools/_conductor.py +104 -6
  105. package/mcp_server/tools/analyzer.py +1 -1
  106. package/mcp_server/tools/devices.py +34 -0
  107. package/mcp_server/wonder_mode/__init__.py +6 -0
  108. package/mcp_server/wonder_mode/diagnosis.py +84 -0
  109. package/mcp_server/wonder_mode/engine.py +493 -0
  110. package/mcp_server/wonder_mode/session.py +114 -0
  111. package/mcp_server/wonder_mode/tools.py +290 -0
  112. package/package.json +2 -2
  113. package/remote_script/LivePilot/__init__.py +1 -1
  114. package/remote_script/LivePilot/browser.py +4 -1
  115. package/remote_script/LivePilot/devices.py +29 -0
  116. package/remote_script/LivePilot/tracks.py +11 -4
  117. package/scripts/generate_tool_catalog.py +131 -0
  118. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,504 @@
1
+ """SongBrain builder — pure computation, zero I/O.
2
+
3
+ Constructs a SongBrain from project brain data, scene/clip analysis,
4
+ motif data, and session memory. MCP tool wrappers call this with
5
+ pre-fetched data from Ableton.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ from collections import Counter
13
+ from typing import Optional
14
+
15
+ from .models import (
16
+ IdentityDrift,
17
+ OpenQuestion,
18
+ SacredElement,
19
+ SectionPurpose,
20
+ SongBrain,
21
+ )
22
+
23
+
24
+ # ── Main builder ──────────────────────────────────────────────────
25
+
26
+
27
+ def build_song_brain(
28
+ session_info: dict,
29
+ scenes: Optional[list[dict]] = None,
30
+ tracks: Optional[list[dict]] = None,
31
+ motif_data: Optional[dict] = None,
32
+ composition_analysis: Optional[dict] = None,
33
+ role_graph: Optional[dict] = None,
34
+ recent_moves: Optional[list[dict]] = None,
35
+ taste_graph: Optional[dict] = None,
36
+ ) -> SongBrain:
37
+ """Build a SongBrain from available session data.
38
+
39
+ All inputs are optional — the builder degrades gracefully when
40
+ data is missing, producing lower-confidence results.
41
+ """
42
+ scenes = scenes or []
43
+ tracks = tracks or []
44
+ motif_data = motif_data or {}
45
+ composition_analysis = composition_analysis or {}
46
+ role_graph = role_graph or {}
47
+ recent_moves = recent_moves or []
48
+
49
+ brain_id = _compute_brain_id(session_info, scenes)
50
+ built_from: dict[str, bool] = {
51
+ "session_info": True,
52
+ "scenes": bool(scenes),
53
+ "tracks": bool(tracks),
54
+ "motif_data": bool(motif_data),
55
+ "composition_analysis": bool(composition_analysis),
56
+ "role_graph": bool(role_graph),
57
+ "recent_moves": bool(recent_moves),
58
+ }
59
+
60
+ identity_core, identity_confidence = _infer_identity_core(
61
+ tracks, motif_data, composition_analysis, role_graph
62
+ )
63
+
64
+ sacred = _detect_sacred_elements(
65
+ tracks, motif_data, composition_analysis, role_graph
66
+ )
67
+
68
+ sections = _infer_section_purposes(scenes, composition_analysis)
69
+ energy_arc = _build_energy_arc(scenes, sections)
70
+ payoff_targets = [s.section_id for s in sections if s.is_payoff]
71
+ open_questions = _detect_open_questions(
72
+ sections, sacred, identity_core, tracks, composition_analysis
73
+ )
74
+
75
+ drift_risk = _estimate_drift_risk(recent_moves, sacred)
76
+
77
+ # Evidence-weighted confidence adjustment
78
+ # Weights: motif=0.4, composition=0.2, role_graph=0.15, scenes=0.15, recent_moves=0.1
79
+ evidence_weights = {
80
+ "motif_data": 0.4,
81
+ "composition_analysis": 0.2,
82
+ "role_graph": 0.15,
83
+ "scenes": 0.15,
84
+ "recent_moves": 0.1,
85
+ }
86
+ evidence_score = sum(
87
+ weight for source, weight in evidence_weights.items()
88
+ if built_from.get(source, False)
89
+ )
90
+ # Adjust identity confidence by evidence availability
91
+ adjusted_confidence = round(identity_confidence * (0.4 + 0.6 * evidence_score), 3)
92
+
93
+ evidence_breakdown = {
94
+ "raw_confidence": identity_confidence,
95
+ "evidence_score": round(evidence_score, 3),
96
+ "adjusted_confidence": adjusted_confidence,
97
+ "sources": {
98
+ source: {"available": built_from.get(source, False), "weight": weight}
99
+ for source, weight in evidence_weights.items()
100
+ },
101
+ }
102
+
103
+ return SongBrain(
104
+ brain_id=brain_id,
105
+ identity_core=identity_core,
106
+ identity_confidence=adjusted_confidence,
107
+ sacred_elements=sacred,
108
+ section_purposes=sections,
109
+ energy_arc=energy_arc,
110
+ identity_drift_risk=drift_risk,
111
+ payoff_targets=payoff_targets,
112
+ open_questions=open_questions,
113
+ evidence_breakdown=evidence_breakdown,
114
+ built_from=built_from,
115
+ )
116
+
117
+
118
+ # ── Identity core inference ───────────────────────────────────────
119
+
120
+
121
+ def _infer_identity_core(
122
+ tracks: list[dict],
123
+ motif_data: dict,
124
+ composition: dict,
125
+ role_graph: dict,
126
+ ) -> tuple[str, float]:
127
+ """Infer the single strongest defining idea in the session.
128
+
129
+ Returns (description, confidence).
130
+ """
131
+ candidates: list[tuple[str, float]] = []
132
+
133
+ # From motif data — most salient recurring motif
134
+ motifs = motif_data.get("motifs", [])
135
+ if motifs:
136
+ top_motif = max(motifs, key=lambda m: m.get("salience", 0))
137
+ salience = top_motif.get("salience", 0)
138
+ if salience > 0.3:
139
+ desc = top_motif.get("description", top_motif.get("name", "recurring motif"))
140
+ candidates.append((f"Recurring motif: {desc}", min(0.9, salience)))
141
+
142
+ # From composition — dominant emotional arc
143
+ arc_type = composition.get("arc_type", "")
144
+ if arc_type:
145
+ candidates.append((f"Emotional arc: {arc_type}", 0.6))
146
+
147
+ # From role graph — dominant texture
148
+ # role_graph format: {track_name: {index: int, role: str}}
149
+ if role_graph:
150
+ role_counts = Counter(
151
+ info.get("role", "unknown")
152
+ for info in role_graph.values()
153
+ if isinstance(info, dict)
154
+ )
155
+ role_counts.pop("unknown", None)
156
+ if role_counts:
157
+ dominant_role = role_counts.most_common(1)[0]
158
+ candidates.append((f"Dominant texture: {dominant_role[0]}", 0.5))
159
+
160
+ # From track analysis — genre/style cues
161
+ track_names = [t.get("name", "").lower() for t in tracks]
162
+ genre_cues = _detect_genre_cues(track_names)
163
+ if genre_cues:
164
+ candidates.append((f"Style: {genre_cues}", 0.4))
165
+
166
+ if not candidates:
167
+ # Fallback: describe by track count and tempo
168
+ return ("Emerging piece — identity not yet established", 0.2)
169
+
170
+ best = max(candidates, key=lambda c: c[1])
171
+ return best
172
+
173
+
174
+ def _detect_genre_cues(track_names: list[str]) -> str:
175
+ """Simple genre/style detection from track naming patterns."""
176
+ cue_map = {
177
+ "808": "trap/hip-hop",
178
+ "kick": "beat-driven",
179
+ "pad": "atmospheric",
180
+ "strings": "orchestral",
181
+ "bass": "bass-forward",
182
+ "vocal": "vocal-driven",
183
+ "synth": "synth-based",
184
+ "guitar": "guitar-based",
185
+ "piano": "keys-driven",
186
+ "ambient": "ambient",
187
+ "drone": "drone/textural",
188
+ }
189
+ found = Counter()
190
+ for name in track_names:
191
+ for keyword, cue in cue_map.items():
192
+ if keyword in name:
193
+ found[cue] += 1
194
+
195
+ if not found:
196
+ return ""
197
+ top = found.most_common(2)
198
+ return ", ".join(c[0] for c in top)
199
+
200
+
201
+ # ── Sacred elements ───────────────────────────────────────────────
202
+
203
+
204
+ def _detect_sacred_elements(
205
+ tracks: list[dict],
206
+ motif_data: dict,
207
+ composition: dict,
208
+ role_graph: dict,
209
+ ) -> list[SacredElement]:
210
+ """Detect elements that should not be casually damaged.
211
+
212
+ Conservative by default — prefer under-protecting nothing
213
+ over over-editing the hook.
214
+ """
215
+ sacred: list[SacredElement] = []
216
+
217
+ # High-salience motifs are sacred
218
+ for motif in motif_data.get("motifs", []):
219
+ if motif.get("salience", 0) > 0.5:
220
+ sacred.append(SacredElement(
221
+ element_type="motif",
222
+ description=motif.get("description", motif.get("name", "motif")),
223
+ location=motif.get("location", ""),
224
+ salience=motif.get("salience", 0.6),
225
+ confidence=0.7,
226
+ ))
227
+
228
+ # Lead/hook tracks from role graph
229
+ # role_graph format: {track_name: {index: int, role: str}}
230
+ for track_name, role_info in role_graph.items():
231
+ if not isinstance(role_info, dict):
232
+ continue
233
+ role = role_info.get("role", "")
234
+ if role in ("lead",):
235
+ sacred.append(SacredElement(
236
+ element_type="texture",
237
+ description=f"{track_name} (lead role)",
238
+ location=track_name,
239
+ salience=0.7,
240
+ confidence=0.6,
241
+ ))
242
+
243
+ # Primary groove (if clearly defined)
244
+ groove_tracks = [
245
+ t for t in tracks
246
+ if any(kw in t.get("name", "").lower() for kw in ("drum", "beat", "kick", "hat", "perc"))
247
+ ]
248
+ if groove_tracks:
249
+ sacred.append(SacredElement(
250
+ element_type="groove",
251
+ description="Primary rhythmic foundation",
252
+ location=groove_tracks[0].get("name", "drums"),
253
+ salience=0.6,
254
+ confidence=0.5,
255
+ ))
256
+
257
+ return sacred
258
+
259
+
260
+ # ── Section purposes ──────────────────────────────────────────────
261
+
262
+
263
+ def _infer_section_purposes(
264
+ scenes: list[dict],
265
+ composition: dict,
266
+ ) -> list[SectionPurpose]:
267
+ """Infer what each section is trying to do emotionally."""
268
+ sections: list[SectionPurpose] = []
269
+
270
+ # From composition analysis if available
271
+ comp_sections = composition.get("sections", [])
272
+ if comp_sections:
273
+ for sec in comp_sections:
274
+ sections.append(SectionPurpose(
275
+ section_id=sec.get("id", sec.get("name", "")),
276
+ label=sec.get("label", sec.get("name", "")),
277
+ emotional_intent=sec.get("intent", sec.get("purpose", "")),
278
+ energy_level=sec.get("energy", 0.5),
279
+ is_payoff=sec.get("is_payoff", False),
280
+ confidence=0.7,
281
+ ))
282
+ return sections
283
+
284
+ # Fallback: infer from scene names
285
+ for i, scene in enumerate(scenes):
286
+ name = scene.get("name", f"Scene {i}")
287
+ label, intent, energy, is_payoff = _classify_scene_name(name, i, len(scenes))
288
+ sections.append(SectionPurpose(
289
+ section_id=f"scene_{i}",
290
+ label=label,
291
+ emotional_intent=intent,
292
+ energy_level=energy,
293
+ is_payoff=is_payoff,
294
+ confidence=0.4,
295
+ ))
296
+
297
+ return sections
298
+
299
+
300
+ def _classify_scene_name(
301
+ name: str, index: int, total: int
302
+ ) -> tuple[str, str, float, bool]:
303
+ """Classify a scene by its name into (label, intent, energy, is_payoff)."""
304
+ name_lower = name.lower()
305
+
306
+ patterns = {
307
+ "intro": ("intro", "establish mood", 0.3, False),
308
+ "verse": ("verse", "develop narrative", 0.5, False),
309
+ "chorus": ("chorus", "deliver hook", 0.8, True),
310
+ "drop": ("drop", "peak energy release", 0.9, True),
311
+ "bridge": ("bridge", "contrast and transition", 0.5, False),
312
+ "break": ("breakdown", "reduce and create anticipation", 0.3, False),
313
+ "build": ("buildup", "create tension", 0.6, False),
314
+ "outro": ("outro", "resolve and fade", 0.2, False),
315
+ "hook": ("hook", "deliver memorable idea", 0.8, True),
316
+ }
317
+
318
+ for keyword, (label, intent, energy, payoff) in patterns.items():
319
+ if keyword in name_lower:
320
+ return label, intent, energy, payoff
321
+
322
+ # Position-based fallback
323
+ position = index / max(total - 1, 1)
324
+ if position < 0.15:
325
+ return "opening", "establish mood", 0.3, False
326
+ elif position > 0.85:
327
+ return "closing", "resolve", 0.3, False
328
+ else:
329
+ return "section", "develop", 0.5, False
330
+
331
+
332
+ # ── Energy arc ────────────────────────────────────────────────────
333
+
334
+
335
+ def _build_energy_arc(
336
+ scenes: list[dict],
337
+ sections: list[SectionPurpose],
338
+ ) -> list[float]:
339
+ """Build ordered energy levels across sections."""
340
+ if sections:
341
+ return [s.energy_level for s in sections]
342
+ return [0.5] * len(scenes) if scenes else []
343
+
344
+
345
+ # ── Open questions ────────────────────────────────────────────────
346
+
347
+
348
+ def _detect_open_questions(
349
+ sections: list[SectionPurpose],
350
+ sacred: list[SacredElement],
351
+ identity_core: str,
352
+ tracks: list[dict],
353
+ composition: dict,
354
+ ) -> list[OpenQuestion]:
355
+ """Detect unresolved creative questions about the song."""
356
+ questions: list[OpenQuestion] = []
357
+
358
+ # No clear identity
359
+ if "not yet established" in identity_core.lower():
360
+ questions.append(OpenQuestion(
361
+ question="What is this track's defining idea?",
362
+ domain="identity",
363
+ priority=0.9,
364
+ ))
365
+
366
+ # No payoff sections
367
+ payoffs = [s for s in sections if s.is_payoff]
368
+ if sections and not payoffs:
369
+ questions.append(OpenQuestion(
370
+ question="No section is marked as a payoff/arrival — where does the song deliver?",
371
+ domain="arrangement",
372
+ priority=0.8,
373
+ ))
374
+
375
+ # Single section (loop, no form)
376
+ if len(sections) <= 1 and len(tracks) > 2:
377
+ questions.append(OpenQuestion(
378
+ question="The track appears to be a single loop — is there intended form?",
379
+ domain="arrangement",
380
+ priority=0.7,
381
+ ))
382
+
383
+ # No sacred elements
384
+ if not sacred:
385
+ questions.append(OpenQuestion(
386
+ question="No clearly sacred elements detected — what should be preserved?",
387
+ domain="identity",
388
+ priority=0.6,
389
+ ))
390
+
391
+ # Missing sections (common gaps)
392
+ labels = {s.label for s in sections}
393
+ if len(sections) > 3 and "intro" not in labels:
394
+ questions.append(OpenQuestion(
395
+ question="No intro section — does the track need an opening?",
396
+ domain="arrangement",
397
+ priority=0.4,
398
+ ))
399
+
400
+ return questions
401
+
402
+
403
+ # ── Drift estimation ──────────────────────────────────────────────
404
+
405
+
406
+ def _estimate_drift_risk(
407
+ recent_moves: list[dict],
408
+ sacred: list[SacredElement],
409
+ ) -> float:
410
+ """Estimate how much recent edits are moving the song away from itself.
411
+
412
+ Checks two signals:
413
+ 1. Moves that touch sacred element locations (scope.track matches)
414
+ 2. Moves that were undone (kept=False) — instability signal
415
+ """
416
+ if not recent_moves:
417
+ return 0.0
418
+
419
+ sacred_locations = {e.location.lower() for e in sacred if e.location}
420
+ sacred_types = {e.element_type.lower() for e in sacred if e.element_type}
421
+ drift_signals = 0
422
+ total_moves = len(recent_moves)
423
+
424
+ for move in recent_moves:
425
+ # Check if the move's scope touches a sacred track
426
+ scope_track = move.get("scope", {}).get("track", "")
427
+ if scope_track and scope_track.lower() in sacred_locations:
428
+ drift_signals += 1
429
+ continue
430
+
431
+ # Check if move engine/intent relates to sacred element types
432
+ intent = move.get("intent", "").lower()
433
+ for stype in sacred_types:
434
+ if stype in intent:
435
+ drift_signals += 1
436
+ break
437
+
438
+ # Undone moves are a mild drift signal (instability)
439
+ if move.get("kept") is False:
440
+ drift_signals += 0.5
441
+
442
+ if total_moves == 0:
443
+ return 0.0
444
+ return min(1.0, drift_signals / max(total_moves, 1) * 1.5)
445
+
446
+
447
+ # ── Identity drift detection ─────────────────────────────────────
448
+
449
+
450
+ def detect_identity_drift(
451
+ before: SongBrain,
452
+ after: SongBrain,
453
+ ) -> IdentityDrift:
454
+ """Compare two SongBrain snapshots to detect identity drift."""
455
+ drift = IdentityDrift()
456
+
457
+ # Identity core change
458
+ if before.identity_core != after.identity_core:
459
+ drift.changed_elements.append("identity_core")
460
+ drift.drift_score += 0.3
461
+
462
+ # Sacred element damage
463
+ before_sacred = {e.description for e in before.sacred_elements}
464
+ after_sacred = {e.description for e in after.sacred_elements}
465
+ lost = before_sacred - after_sacred
466
+ if lost:
467
+ drift.sacred_damage = list(lost)
468
+ drift.drift_score += 0.2 * len(lost)
469
+
470
+ # Energy arc shift
471
+ if before.energy_arc and after.energy_arc:
472
+ min_len = min(len(before.energy_arc), len(after.energy_arc))
473
+ if min_len > 0:
474
+ diff = sum(
475
+ abs(before.energy_arc[i] - after.energy_arc[i])
476
+ for i in range(min_len)
477
+ ) / min_len
478
+ drift.energy_arc_shift = round(diff, 3)
479
+ drift.drift_score += diff * 0.2
480
+
481
+ drift.drift_score = min(1.0, round(drift.drift_score, 3))
482
+
483
+ # Recommendation
484
+ if drift.drift_score < 0.15:
485
+ drift.recommendation = "safe"
486
+ elif drift.drift_score < 0.4:
487
+ drift.recommendation = "caution"
488
+ else:
489
+ drift.recommendation = "rollback_suggested"
490
+
491
+ return drift
492
+
493
+
494
+ # ── Helpers ───────────────────────────────────────────────────────
495
+
496
+
497
+ def _compute_brain_id(session_info: dict, scenes: list[dict]) -> str:
498
+ """Deterministic brain ID from session state."""
499
+ seed = json.dumps({
500
+ "tempo": session_info.get("tempo"),
501
+ "track_count": session_info.get("track_count"),
502
+ "scene_count": len(scenes),
503
+ }, sort_keys=True)
504
+ return hashlib.sha256(seed.encode()).hexdigest()[:12]
@@ -0,0 +1,136 @@
1
+ """SongBrain data models — pure dataclasses, zero I/O.
2
+
3
+ SongBrain is the runtime object that captures the musical identity of the
4
+ current piece. It is distinct from project topology and from cross-session
5
+ user taste.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass, field
11
+ from typing import Optional
12
+
13
+
14
+ @dataclass
15
+ class SacredElement:
16
+ """A musical element that should not be casually damaged."""
17
+
18
+ element_type: str = "" # "motif", "texture", "groove", "progression", "timbre"
19
+ description: str = ""
20
+ location: str = "" # track/clip reference
21
+ salience: float = 0.0 # 0-1 how central to identity
22
+ confidence: float = 0.5
23
+
24
+ def to_dict(self) -> dict:
25
+ return asdict(self)
26
+
27
+
28
+ @dataclass
29
+ class SectionPurpose:
30
+ """What a section is trying to do emotionally."""
31
+
32
+ section_id: str = ""
33
+ label: str = "" # "intro", "verse", "chorus", "bridge", "breakdown", "outro"
34
+ emotional_intent: str = "" # "build tension", "release", "establish mood", etc.
35
+ energy_level: float = 0.5 # 0-1
36
+ is_payoff: bool = False # whether this section should feel like an arrival
37
+ confidence: float = 0.5
38
+
39
+ def to_dict(self) -> dict:
40
+ return asdict(self)
41
+
42
+
43
+ @dataclass
44
+ class OpenQuestion:
45
+ """An unresolved creative question about the song."""
46
+
47
+ question: str = ""
48
+ domain: str = "" # "arrangement", "mix", "harmony", "sound_design", "identity"
49
+ priority: float = 0.5 # 0-1
50
+
51
+ def to_dict(self) -> dict:
52
+ return asdict(self)
53
+
54
+
55
+ @dataclass
56
+ class SongBrain:
57
+ """The musical identity of the current piece.
58
+
59
+ Built from project brain, composition analysis, motif data, phrase
60
+ similarity, role graph, and recent accepted moves.
61
+ """
62
+
63
+ brain_id: str = ""
64
+
65
+ # Core identity
66
+ identity_core: str = "" # the strongest defining idea in the session
67
+ identity_confidence: float = 0.5 # 0-1
68
+
69
+ # Sacred elements
70
+ sacred_elements: list[SacredElement] = field(default_factory=list)
71
+
72
+ # Section purposes
73
+ section_purposes: list[SectionPurpose] = field(default_factory=list)
74
+
75
+ # Energy arc across sections (ordered floats 0-1)
76
+ energy_arc: list[float] = field(default_factory=list)
77
+
78
+ # Identity drift risk (0=stable, 1=drifting away from itself)
79
+ identity_drift_risk: float = 0.0
80
+
81
+ # Payoff targets — sections that should feel like arrival points
82
+ payoff_targets: list[str] = field(default_factory=list)
83
+
84
+ # Open questions the song has not resolved
85
+ open_questions: list[OpenQuestion] = field(default_factory=list)
86
+
87
+ # Evidence breakdown — what data informed each inference
88
+ evidence_breakdown: dict = field(default_factory=dict)
89
+
90
+ # Metadata
91
+ built_from: dict = field(default_factory=dict) # what data sources contributed
92
+
93
+ def to_dict(self) -> dict:
94
+ return {
95
+ "brain_id": self.brain_id,
96
+ "identity_core": self.identity_core,
97
+ "identity_confidence": self.identity_confidence,
98
+ "sacred_elements": [e.to_dict() for e in self.sacred_elements],
99
+ "section_purposes": [s.to_dict() for s in self.section_purposes],
100
+ "energy_arc": self.energy_arc,
101
+ "identity_drift_risk": self.identity_drift_risk,
102
+ "payoff_targets": self.payoff_targets,
103
+ "open_questions": [q.to_dict() for q in self.open_questions],
104
+ "evidence_breakdown": self.evidence_breakdown,
105
+ "built_from": self.built_from,
106
+ }
107
+
108
+ @property
109
+ def summary(self) -> str:
110
+ """Human-readable one-line summary."""
111
+ parts = []
112
+ if self.identity_core:
113
+ parts.append(f"Identity: {self.identity_core}")
114
+ sacred_count = len(self.sacred_elements)
115
+ if sacred_count:
116
+ parts.append(f"{sacred_count} sacred element(s)")
117
+ section_count = len(self.section_purposes)
118
+ if section_count:
119
+ parts.append(f"{section_count} section(s)")
120
+ if self.identity_drift_risk > 0.3:
121
+ parts.append(f"drift risk {self.identity_drift_risk:.0%}")
122
+ return " | ".join(parts) if parts else "No identity established yet"
123
+
124
+
125
+ @dataclass
126
+ class IdentityDrift:
127
+ """Result of comparing two SongBrain snapshots."""
128
+
129
+ drift_score: float = 0.0 # 0=identical, 1=completely different
130
+ changed_elements: list[str] = field(default_factory=list)
131
+ sacred_damage: list[str] = field(default_factory=list) # sacred elements affected
132
+ energy_arc_shift: float = 0.0
133
+ recommendation: str = "" # "safe", "caution", "rollback_suggested"
134
+
135
+ def to_dict(self) -> dict:
136
+ return asdict(self)