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,328 @@
1
+ """TasteGraph — extended taste model for personalized move ranking.
2
+
3
+ Builds on the existing TasteMemoryStore and AntiMemoryStore to add:
4
+ - Move family scoring (which semantic move families does the user prefer?)
5
+ - Device affinities (which synths, effects, and kits resonate?)
6
+ - Novelty band (how experimental vs. conservative does the user want to be?)
7
+ - Evidence tracking (what decisions informed each inference?)
8
+
9
+ The TasteGraph is the bridge between "what moves are available" and
10
+ "what moves does THIS user want." It powers rank_moves_by_taste.
11
+
12
+ Pure computation — no I/O. Updated from outcome data.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional
20
+
21
+
22
+ @dataclass
23
+ class MoveFamilyScore:
24
+ """How much a user favors a semantic move family."""
25
+ family: str # mix, arrangement, transition, sound_design
26
+ score: float = 0.0 # -1 to 1 (negative = dislikes, positive = prefers)
27
+ kept_count: int = 0
28
+ undone_count: int = 0
29
+ last_updated_ms: int = 0
30
+
31
+ def to_dict(self) -> dict:
32
+ return {
33
+ "family": self.family,
34
+ "score": round(self.score, 3),
35
+ "kept_count": self.kept_count,
36
+ "undone_count": self.undone_count,
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class DeviceAffinity:
42
+ """How much a user likes a particular device or device class."""
43
+ device_name: str
44
+ affinity: float = 0.0 # -1 to 1
45
+ use_count: int = 0
46
+ last_used_ms: int = 0
47
+
48
+ def to_dict(self) -> dict:
49
+ return {
50
+ "device_name": self.device_name,
51
+ "affinity": round(self.affinity, 3),
52
+ "use_count": self.use_count,
53
+ }
54
+
55
+
56
+ @dataclass
57
+ class TasteGraph:
58
+ """Extended taste model for personalized ranking.
59
+
60
+ Combines dimension preferences, move family scores, device affinities,
61
+ and novelty tolerance into a single queryable model.
62
+ """
63
+ # Core dimension preferences (from existing TasteMemoryStore)
64
+ dimension_weights: dict[str, float] = field(default_factory=dict)
65
+
66
+ # Dimension avoidances (from AntiMemoryStore)
67
+ dimension_avoidances: dict[str, str] = field(default_factory=dict)
68
+
69
+ # Move family preferences
70
+ move_family_scores: dict[str, MoveFamilyScore] = field(default_factory=dict)
71
+
72
+ # Device preferences
73
+ device_affinities: dict[str, DeviceAffinity] = field(default_factory=dict)
74
+
75
+ # Novelty tolerance: 0 = very conservative, 1 = very experimental
76
+ novelty_band: float = 0.5
77
+
78
+ # Total evidence count (how many decisions informed this graph)
79
+ evidence_count: int = 0
80
+ last_updated_ms: int = 0
81
+
82
+ def to_dict(self) -> dict:
83
+ return {
84
+ "dimension_weights": self.dimension_weights,
85
+ "dimension_avoidances": self.dimension_avoidances,
86
+ "move_family_scores": {
87
+ k: v.to_dict() for k, v in self.move_family_scores.items()
88
+ },
89
+ "device_affinities": {
90
+ k: v.to_dict()
91
+ for k, v in sorted(
92
+ self.device_affinities.items(),
93
+ key=lambda x: -x[1].affinity,
94
+ )[:10] # Top 10 only
95
+ },
96
+ "novelty_band": round(self.novelty_band, 3),
97
+ "evidence_count": self.evidence_count,
98
+ }
99
+
100
+ # ── Update methods ───────────────────────────────────────────────
101
+
102
+ # Persistent store reference (set by build_taste_graph when available)
103
+ _persistent_store: object = None
104
+
105
+ def record_move_outcome(
106
+ self, move_id: str, family: str, kept: bool, score: float = 0.0
107
+ ) -> None:
108
+ """Update taste from a kept/undone semantic move."""
109
+ now = int(time.time() * 1000)
110
+
111
+ if family not in self.move_family_scores:
112
+ self.move_family_scores[family] = MoveFamilyScore(family=family)
113
+
114
+ fam = self.move_family_scores[family]
115
+ if kept:
116
+ fam.score = min(1.0, fam.score + 0.1)
117
+ fam.kept_count += 1
118
+ else:
119
+ fam.score = max(-1.0, fam.score - 0.12)
120
+ fam.undone_count += 1
121
+ fam.last_updated_ms = now
122
+
123
+ self.evidence_count += 1
124
+ self.last_updated_ms = now
125
+
126
+ # Write-back to persistent store
127
+ if self._persistent_store is not None:
128
+ try:
129
+ self._persistent_store.record_move_outcome(move_id, family, kept, score)
130
+ except Exception:
131
+ pass # persistence is best-effort
132
+
133
+ def record_device_use(self, device_name: str, positive: bool = True) -> None:
134
+ """Update device affinity from usage."""
135
+ now = int(time.time() * 1000)
136
+
137
+ if device_name not in self.device_affinities:
138
+ self.device_affinities[device_name] = DeviceAffinity(
139
+ device_name=device_name
140
+ )
141
+
142
+ dev = self.device_affinities[device_name]
143
+ dev.use_count += 1
144
+ if positive:
145
+ dev.affinity = min(1.0, dev.affinity + 0.05)
146
+ else:
147
+ dev.affinity = max(-1.0, dev.affinity - 0.08)
148
+ dev.last_used_ms = now
149
+
150
+ self.evidence_count += 1
151
+ self.last_updated_ms = now
152
+
153
+ def update_novelty_from_experiment(self, chose_bold: bool) -> None:
154
+ """Shift novelty band based on experiment choices."""
155
+ if chose_bold:
156
+ self.novelty_band = min(1.0, self.novelty_band + 0.05)
157
+ else:
158
+ self.novelty_band = max(0.0, self.novelty_band - 0.05)
159
+
160
+ # ── Ranking ──────────────────────────────────────────────────────
161
+
162
+ def rank_moves(self, move_specs: list[dict]) -> list[dict]:
163
+ """Rank a list of semantic move dicts by taste fit.
164
+
165
+ Each move dict should have: move_id, family, targets, risk_level.
166
+ Returns the same dicts with added 'taste_score' field, sorted desc.
167
+ """
168
+ ranked = []
169
+ for move in move_specs:
170
+ taste_score = 0.5 # Neutral baseline
171
+
172
+ # Family preference
173
+ family = move.get("family", "")
174
+ fam_score = self.move_family_scores.get(family)
175
+ if fam_score:
176
+ taste_score += fam_score.score * 0.3
177
+
178
+ # Dimension alignment
179
+ targets = move.get("targets", {})
180
+ for dim, weight in targets.items():
181
+ dim_pref = self.dimension_weights.get(dim, 0.0)
182
+ taste_score += dim_pref * weight * 0.2
183
+
184
+ # Anti-preference penalty
185
+ for dim in targets:
186
+ if dim in self.dimension_avoidances:
187
+ taste_score -= 0.3
188
+
189
+ # Novelty/risk alignment
190
+ risk = move.get("risk_level", "low")
191
+ risk_val = {"low": 0.2, "medium": 0.5, "high": 0.8}.get(risk, 0.5)
192
+ novelty_match = 1.0 - abs(risk_val - self.novelty_band)
193
+ taste_score += novelty_match * 0.1
194
+
195
+ # Clamp
196
+ taste_score = max(0.0, min(1.0, taste_score))
197
+
198
+ result = dict(move)
199
+ result["taste_score"] = round(taste_score, 3)
200
+ ranked.append(result)
201
+
202
+ ranked.sort(key=lambda x: -x["taste_score"])
203
+ return ranked
204
+
205
+ def explain(self) -> dict:
206
+ """Generate a human-readable explanation of taste inferences."""
207
+ explanations = []
208
+
209
+ # Top move families
210
+ top_families = sorted(
211
+ self.move_family_scores.values(),
212
+ key=lambda f: -f.score,
213
+ )[:3]
214
+ for fam in top_families:
215
+ if fam.score > 0.1:
216
+ explanations.append(
217
+ f"Prefers {fam.family} moves (score {fam.score:.2f}, "
218
+ f"{fam.kept_count} kept, {fam.undone_count} undone)"
219
+ )
220
+ elif fam.score < -0.1:
221
+ explanations.append(
222
+ f"Tends to reject {fam.family} moves (score {fam.score:.2f})"
223
+ )
224
+
225
+ # Novelty
226
+ if self.novelty_band > 0.65:
227
+ explanations.append("Prefers experimental/bold approaches")
228
+ elif self.novelty_band < 0.35:
229
+ explanations.append("Prefers conservative/safe approaches")
230
+
231
+ # Top devices
232
+ top_devs = sorted(
233
+ self.device_affinities.values(),
234
+ key=lambda d: -d.affinity,
235
+ )[:3]
236
+ for dev in top_devs:
237
+ if dev.affinity > 0.1 and dev.use_count >= 2:
238
+ explanations.append(
239
+ f"Likes {dev.device_name} (used {dev.use_count}x)"
240
+ )
241
+
242
+ # Avoidances
243
+ for dim, direction in self.dimension_avoidances.items():
244
+ explanations.append(f"Avoids {direction} {dim}")
245
+
246
+ return {
247
+ "evidence_count": self.evidence_count,
248
+ "novelty_band": round(self.novelty_band, 3),
249
+ "explanations": explanations,
250
+ }
251
+
252
+
253
+ # ── Builder ──────────────────────────────────────────────────────────────────
254
+
255
+ def build_taste_graph(
256
+ taste_store=None, # TasteMemoryStore
257
+ anti_store=None, # AntiMemoryStore
258
+ persistent_store=None, # PersistentTasteStore (optional)
259
+ ) -> TasteGraph:
260
+ """Build a TasteGraph from existing memory stores.
261
+
262
+ When persistent_store is provided, hydrates move_family_scores,
263
+ device_affinities, and novelty_band from disk — these survive
264
+ server restart.
265
+ """
266
+ graph = TasteGraph()
267
+
268
+ # Session-scoped dimensions (in-memory)
269
+ if taste_store:
270
+ for dim in taste_store.get_taste_dimensions():
271
+ if dim.evidence_count > 0:
272
+ graph.dimension_weights[dim.name] = dim.value
273
+
274
+ if anti_store:
275
+ for pref in anti_store.get_anti_preferences():
276
+ graph.dimension_avoidances[pref.dimension] = pref.direction
277
+
278
+ # Persistent state (from disk)
279
+ if persistent_store is not None:
280
+ persisted = persistent_store.get_all()
281
+
282
+ # Move family scores
283
+ for move_id, outcome in persisted.get("move_outcomes", {}).items():
284
+ family = outcome.get("family", "")
285
+ if family and family not in graph.move_family_scores:
286
+ from .taste_graph import MoveFamilyScore
287
+ graph.move_family_scores[family] = MoveFamilyScore(family=family)
288
+ if family:
289
+ fam = graph.move_family_scores[family]
290
+ fam.kept_count += outcome.get("kept_count", 0)
291
+ fam.undone_count += outcome.get("undone_count", 0)
292
+ total = fam.kept_count + fam.undone_count
293
+ if total > 0:
294
+ fam.score = round((fam.kept_count - fam.undone_count) / total, 3)
295
+
296
+ # Novelty band
297
+ graph.novelty_band = persisted.get("novelty_band", 0.5)
298
+
299
+ # Device affinities
300
+ for dev_name, dev_data in persisted.get("device_affinities", {}).items():
301
+ from .taste_graph import DeviceAffinity
302
+ graph.device_affinities[dev_name] = DeviceAffinity(
303
+ device_name=dev_name,
304
+ affinity=dev_data.get("affinity", 0.0),
305
+ use_count=dev_data.get("use_count", 0),
306
+ )
307
+
308
+ # Evidence count
309
+ graph.evidence_count = max(
310
+ graph.evidence_count, persisted.get("evidence_count", 0)
311
+ )
312
+
313
+ # Dimension weights from persistent store (merged, session takes precedence)
314
+ for dim, val in persisted.get("dimension_weights", {}).items():
315
+ if dim not in graph.dimension_weights:
316
+ graph.dimension_weights[dim] = val
317
+
318
+ # Anti-preferences from persistent store
319
+ for anti in persisted.get("anti_preferences", []):
320
+ dim = anti.get("dimension", "")
321
+ direction = anti.get("direction", "")
322
+ if dim and dim not in graph.dimension_avoidances:
323
+ graph.dimension_avoidances[dim] = direction
324
+
325
+ # Attach persistent store for write-back
326
+ graph._persistent_store = persistent_store
327
+
328
+ return graph
@@ -110,3 +110,102 @@ def get_taste_dimensions(ctx: Context) -> dict:
110
110
  """Return all taste dimensions — user preferences inferred from kept/undone outcomes."""
111
111
  store = _get_taste_memory(ctx)
112
112
  return store.to_dict()
113
+
114
+
115
+ # ── Taste Graph (V2) ────────────────────────────────────────────────
116
+
117
+
118
+ @mcp.tool()
119
+ def get_taste_graph(ctx: Context) -> dict:
120
+ """Get the full TasteGraph — extended preferences including move families,
121
+ device affinities, novelty tolerance, and dimension weights.
122
+
123
+ The TasteGraph combines taste dimensions, anti-preferences, and
124
+ move/device tracking into a single model for personalized ranking.
125
+ """
126
+ from .taste_graph import build_taste_graph
127
+
128
+ taste_store = _get_taste_memory(ctx)
129
+ anti_store = _get_anti_memory(ctx)
130
+ graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
131
+ return graph.to_dict()
132
+
133
+
134
+ @mcp.tool()
135
+ def explain_taste_inference(ctx: Context) -> dict:
136
+ """Explain why the system thinks the user prefers certain approaches.
137
+
138
+ Returns human-readable explanations of inferred taste based on
139
+ evidence from kept moves, undone moves, device usage, and anti-preferences.
140
+ """
141
+ from .taste_graph import build_taste_graph
142
+
143
+ taste_store = _get_taste_memory(ctx)
144
+ anti_store = _get_anti_memory(ctx)
145
+ graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
146
+ return graph.explain()
147
+
148
+
149
+ @mcp.tool()
150
+ def rank_moves_by_taste(
151
+ ctx: Context,
152
+ move_specs: list,
153
+ ) -> dict:
154
+ """Rank semantic moves by taste fit for the current user.
155
+
156
+ move_specs: list of dicts with {move_id, family, targets, risk_level}
157
+ Returns: the same moves sorted by taste_score (descending).
158
+
159
+ Use this after propose_next_best_move to personalize the ranking.
160
+ """
161
+ from .taste_graph import build_taste_graph
162
+
163
+ taste_store = _get_taste_memory(ctx)
164
+ anti_store = _get_anti_memory(ctx)
165
+ graph = build_taste_graph(taste_store=taste_store, anti_store=anti_store)
166
+
167
+ if isinstance(move_specs, str):
168
+ import json
169
+ move_specs = json.loads(move_specs)
170
+
171
+ ranked = graph.rank_moves(move_specs)
172
+ return {"ranked_moves": ranked, "count": len(ranked)}
173
+
174
+
175
+ @mcp.tool()
176
+ def record_positive_preference(
177
+ ctx: Context,
178
+ dimension: str,
179
+ direction: str,
180
+ evidence: str = "",
181
+ ) -> dict:
182
+ """Record a user preference for more/less of a dimension.
183
+
184
+ dimension: quality axis (e.g., "warmth", "width", "punch")
185
+ direction: "increase" or "decrease"
186
+ evidence: optional note about what triggered this preference
187
+
188
+ Complements record_anti_preference — this records what users LIKE,
189
+ not just what they dislike.
190
+ """
191
+ taste_store = _get_taste_memory(ctx)
192
+ # Find matching outcome signals for this dimension+direction
193
+ from ..memory.taste_memory import _OUTCOME_SIGNALS
194
+ matching_signals = []
195
+ dim_signals = _OUTCOME_SIGNALS.get(dimension, {})
196
+ for sig_name, adjustment in dim_signals.items():
197
+ # "increase" preference → match positive-adjustment signals (kept)
198
+ # "decrease" preference → match negative-adjustment signals (undone/less)
199
+ if direction == "increase" and adjustment > 0:
200
+ matching_signals.append(sig_name)
201
+ elif direction == "decrease" and adjustment < 0:
202
+ matching_signals.append(sig_name)
203
+ if matching_signals:
204
+ taste_store.update_from_outcome({"signals": matching_signals})
205
+ return {
206
+ "recorded": bool(matching_signals),
207
+ "dimension": dimension,
208
+ "direction": direction,
209
+ "signals_matched": matching_signals,
210
+ "evidence": evidence,
211
+ }
@@ -148,7 +148,7 @@ def run_dynamics_critic(dynamics: DynamicsState) -> list[MixIssue]:
148
148
  recommended_moves=["transient_shaping", "gain_staging"],
149
149
  ))
150
150
 
151
- if dynamics.headroom < 1.0:
151
+ if dynamics.headroom is not None and dynamics.headroom < 1.0:
152
152
  issues.append(MixIssue(
153
153
  issue_type="low_headroom",
154
154
  critic="dynamics",
@@ -266,7 +266,7 @@ def run_translation_critic(
266
266
  ))
267
267
 
268
268
  # Harshness risk: over-compressed + low headroom
269
- if dynamics.over_compressed and dynamics.headroom < 3.0:
269
+ if dynamics.over_compressed and dynamics.headroom is not None and dynamics.headroom < 3.0:
270
270
  issues.append(MixIssue(
271
271
  issue_type="harshness_risk",
272
272
  critic="translation",
@@ -93,7 +93,7 @@ class DynamicsState:
93
93
 
94
94
  crest_factor_db: float = 0.0
95
95
  over_compressed: bool = False
96
- headroom: float = 0.0
96
+ headroom: Optional[float] = None
97
97
 
98
98
  def to_dict(self) -> dict:
99
99
  return asdict(self)
@@ -170,7 +170,7 @@ def build_dynamics_state(
170
170
  peak: master peak level in linear (0-1) or dB.
171
171
  """
172
172
  if rms is None or peak is None or rms <= 0:
173
- return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=0.0)
173
+ return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=None)
174
174
 
175
175
  # If values look like they're in dB (negative), convert to linear
176
176
  if rms < 0:
@@ -181,7 +181,7 @@ def build_dynamics_state(
181
181
  peak_linear = peak if peak else rms
182
182
 
183
183
  if rms_linear <= 0:
184
- return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=0.0)
184
+ return DynamicsState(crest_factor_db=0.0, over_compressed=False, headroom=None)
185
185
 
186
186
  crest = 20 * math.log10(max(peak_linear, 1e-10) / max(rms_linear, 1e-10))
187
187
 
@@ -0,0 +1,8 @@
1
+ """Musical intelligence — pure computation modules for song-level analysis.
2
+
3
+ Detects structural and compositional issues that parameter-level analysis misses:
4
+ - Repetition fatigue: when patterns have been heard too many times
5
+ - Role conflicts: when multiple tracks compete for the same musical role
6
+ - Section purpose: infers what each section is trying to do musically
7
+ - Emotional arc: scores the tension/release curve across the arrangement
8
+ """