livepilot 1.9.23 → 1.10.0

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 (191) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +119 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +144 -13
  6. package/bin/livepilot.js +87 -0
  7. package/installer/codex.js +147 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +21 -4
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  12. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  19. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  20. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  21. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  22. package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
  23. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  24. package/livepilot/skills/livepilot-release/SKILL.md +19 -5
  25. package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
  26. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  27. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  29. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  30. package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
  31. package/livepilot.mcpb +0 -0
  32. package/m4l_device/livepilot_bridge.js +1 -1
  33. package/manifest.json +2 -2
  34. package/mcp_server/__init__.py +1 -1
  35. package/mcp_server/atlas/__init__.py +357 -0
  36. package/mcp_server/atlas/device_atlas.json +44067 -0
  37. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  38. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  39. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  73. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  74. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  75. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  76. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  77. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  78. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  79. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  80. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  81. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  82. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  83. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  84. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  85. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  86. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  87. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  88. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  89. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  90. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  101. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  102. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  103. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  104. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  105. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  106. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  107. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  108. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  109. package/mcp_server/atlas/scanner.py +236 -0
  110. package/mcp_server/atlas/tools.py +224 -0
  111. package/mcp_server/composer/__init__.py +1 -0
  112. package/mcp_server/composer/engine.py +452 -0
  113. package/mcp_server/composer/layer_planner.py +427 -0
  114. package/mcp_server/composer/prompt_parser.py +329 -0
  115. package/mcp_server/composer/tools.py +201 -0
  116. package/mcp_server/connection.py +53 -8
  117. package/mcp_server/corpus/__init__.py +377 -0
  118. package/mcp_server/device_forge/__init__.py +1 -0
  119. package/mcp_server/device_forge/builder.py +377 -0
  120. package/mcp_server/device_forge/models.py +142 -0
  121. package/mcp_server/device_forge/templates.py +483 -0
  122. package/mcp_server/device_forge/tools.py +162 -0
  123. package/mcp_server/hook_hunter/analyzer.py +23 -0
  124. package/mcp_server/hook_hunter/models.py +1 -0
  125. package/mcp_server/hook_hunter/tools.py +4 -2
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_graph.py +68 -1
  128. package/mcp_server/memory/tools.py +15 -4
  129. package/mcp_server/musical_intelligence/detectors.py +14 -1
  130. package/mcp_server/musical_intelligence/tools.py +11 -8
  131. package/mcp_server/persistence/__init__.py +1 -0
  132. package/mcp_server/persistence/base_store.py +82 -0
  133. package/mcp_server/persistence/project_store.py +106 -0
  134. package/mcp_server/persistence/taste_store.py +122 -0
  135. package/mcp_server/preview_studio/models.py +1 -0
  136. package/mcp_server/preview_studio/tools.py +56 -13
  137. package/mcp_server/runtime/capability.py +66 -0
  138. package/mcp_server/runtime/capability_probe.py +137 -0
  139. package/mcp_server/runtime/execution_router.py +143 -0
  140. package/mcp_server/runtime/live_version.py +102 -0
  141. package/mcp_server/runtime/remote_commands.py +87 -0
  142. package/mcp_server/runtime/tools.py +18 -4
  143. package/mcp_server/sample_engine/__init__.py +1 -0
  144. package/mcp_server/sample_engine/analyzer.py +216 -0
  145. package/mcp_server/sample_engine/critics.py +390 -0
  146. package/mcp_server/sample_engine/models.py +193 -0
  147. package/mcp_server/sample_engine/moves.py +127 -0
  148. package/mcp_server/sample_engine/planner.py +186 -0
  149. package/mcp_server/sample_engine/sources.py +540 -0
  150. package/mcp_server/sample_engine/techniques.py +908 -0
  151. package/mcp_server/sample_engine/tools.py +442 -0
  152. package/mcp_server/semantic_moves/__init__.py +3 -0
  153. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  154. package/mcp_server/semantic_moves/mix_moves.py +41 -41
  155. package/mcp_server/semantic_moves/performance_moves.py +13 -13
  156. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  157. package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
  158. package/mcp_server/semantic_moves/tools.py +18 -17
  159. package/mcp_server/semantic_moves/transition_moves.py +16 -16
  160. package/mcp_server/server.py +51 -0
  161. package/mcp_server/services/__init__.py +1 -0
  162. package/mcp_server/services/motif_service.py +67 -0
  163. package/mcp_server/session_continuity/tracker.py +29 -1
  164. package/mcp_server/song_brain/builder.py +28 -1
  165. package/mcp_server/song_brain/models.py +4 -0
  166. package/mcp_server/song_brain/tools.py +20 -2
  167. package/mcp_server/sound_design/critics.py +89 -1
  168. package/mcp_server/splice_client/__init__.py +1 -0
  169. package/mcp_server/splice_client/client.py +347 -0
  170. package/mcp_server/splice_client/models.py +96 -0
  171. package/mcp_server/splice_client/protos/__init__.py +1 -0
  172. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  173. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  174. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  175. package/mcp_server/tools/arrangement.py +69 -0
  176. package/mcp_server/tools/automation.py +15 -2
  177. package/mcp_server/tools/devices.py +117 -6
  178. package/mcp_server/tools/notes.py +37 -4
  179. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  180. package/mcp_server/wonder_mode/engine.py +85 -1
  181. package/mcp_server/wonder_mode/tools.py +6 -1
  182. package/package.json +12 -2
  183. package/remote_script/LivePilot/__init__.py +8 -1
  184. package/remote_script/LivePilot/arrangement.py +114 -0
  185. package/remote_script/LivePilot/browser.py +56 -1
  186. package/remote_script/LivePilot/devices.py +236 -6
  187. package/remote_script/LivePilot/mixing.py +8 -3
  188. package/remote_script/LivePilot/server.py +5 -1
  189. package/remote_script/LivePilot/transport.py +3 -0
  190. package/remote_script/LivePilot/version_detect.py +78 -0
  191. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,216 @@
1
+ """SampleAnalyzer — filename parsing, material classification, mode recommendation.
2
+
3
+ Pure computation for the offline parts. Spectral analysis requires M4L bridge
4
+ and is handled in tools.py which calls these functions + bridge data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from typing import Optional
12
+
13
+ from .models import SampleProfile
14
+
15
+
16
+ # ── Filename Metadata Parsing ───────────────────────────────────────
17
+
18
+ # Key patterns: C, Cm, C#, C#m, Cb, Cbm, Csharp, Csharpmin, etc.
19
+ _KEY_PATTERN = re.compile(
20
+ r'\b([A-G])([b#]|sharp|flat)?(m|min|minor|maj|major)?\b',
21
+ re.IGNORECASE,
22
+ )
23
+
24
+ # BPM patterns: 120bpm, 120_bpm, 120 BPM, or standalone 60-300 range
25
+ _BPM_PATTERN = re.compile(
26
+ r'\b(\d{2,3})\s*(?:bpm)\b', re.IGNORECASE,
27
+ )
28
+ _BPM_STANDALONE = re.compile(
29
+ r'(?:^|[_\-\s])(\d{2,3})(?:[_\-\s]|$)',
30
+ )
31
+
32
+ _KEY_NORMALIZE = {
33
+ "sharp": "#", "flat": "b",
34
+ "min": "m", "minor": "m", "maj": "", "major": "",
35
+ }
36
+
37
+
38
+ def parse_filename_metadata(filename: str) -> dict:
39
+ """Extract key and BPM from a filename string.
40
+
41
+ Returns dict with 'key' (str|None) and 'bpm' (float|None).
42
+ """
43
+ stem = os.path.splitext(os.path.basename(filename))[0]
44
+ # Replace common separators with spaces for easier matching
45
+ normalized = stem.replace("-", " ").replace("_", " ")
46
+
47
+ key = _extract_key(normalized)
48
+ bpm = _extract_bpm(normalized)
49
+
50
+ return {"key": key, "bpm": bpm}
51
+
52
+
53
+ def _extract_key(text: str) -> Optional[str]:
54
+ """Extract musical key from text."""
55
+ matches = list(_KEY_PATTERN.finditer(text))
56
+ for match in matches:
57
+ root = match.group(1).upper()
58
+ accidental = match.group(2) or ""
59
+ quality = match.group(3) or ""
60
+
61
+ # Normalize accidentals
62
+ accidental = _KEY_NORMALIZE.get(accidental.lower(), accidental)
63
+ quality = _KEY_NORMALIZE.get(quality.lower(), quality) if quality else ""
64
+
65
+ # Avoid false positives: single letters that are common words
66
+ full = root + accidental + quality
67
+ if len(full) == 1 and root in ("A", "B", "C", "D", "E", "F", "G"):
68
+ # Single letter — only accept if it looks like it's in a key context
69
+ # Check surrounding chars
70
+ start = match.start()
71
+ end = match.end()
72
+ before = text[start - 1] if start > 0 else " "
73
+ after = text[end] if end < len(text) else " "
74
+ if before.isalpha() or after.isalpha():
75
+ continue # Part of a word, not a key
76
+ return full
77
+ return None
78
+
79
+
80
+ def _extract_bpm(text: str) -> Optional[float]:
81
+ """Extract BPM from text."""
82
+ # Try explicit bpm markers first
83
+ match = _BPM_PATTERN.search(text)
84
+ if match:
85
+ bpm = float(match.group(1))
86
+ if 40 <= bpm <= 300:
87
+ return bpm
88
+
89
+ # Try standalone numbers in valid range
90
+ for match in _BPM_STANDALONE.finditer(text):
91
+ bpm = float(match.group(1))
92
+ if 60 <= bpm <= 250:
93
+ return bpm
94
+ return None
95
+
96
+
97
+ # ── Material Classification ─────────────────────────────────────────
98
+
99
+ _MATERIAL_KEYWORDS: dict[str, list[str]] = {
100
+ "vocal": ["vocal", "vox", "voice", "singer", "acapella", "spoken"],
101
+ "drum_loop": ["drum", "beat", "break", "breakbeat", "loop", "groove",
102
+ "hihat", "hat", "ride", "cymbal", "perc", "percussion",
103
+ "shaker", "tamb", "conga", "bongo", "top"],
104
+ "one_shot": ["kick", "snare", "clap", "snap", "tom", "rim", "hit",
105
+ "oneshot", "one shot", "stab", "shot", "impact"],
106
+ "instrument_loop": ["guitar", "piano", "keys", "bass", "synth",
107
+ "strings", "brass", "horn", "organ", "riff",
108
+ "chord", "arp", "pluck"],
109
+ "texture": ["ambient", "pad", "drone", "atmosphere", "noise",
110
+ "texture", "wash", "evolving", "soundscape"],
111
+ "foley": ["foley", "field", "recording", "room", "nature",
112
+ "water", "metal", "wood", "glass", "paper"],
113
+ "fx": ["fx", "effect", "riser", "sweep", "whoosh", "boom",
114
+ "transition", "downlifter", "uplifter"],
115
+ }
116
+
117
+
118
+ def classify_material_from_name(name: str) -> str:
119
+ """Classify sample material type from filename/name keywords."""
120
+ lower = name.lower().replace("-", " ").replace("_", " ")
121
+
122
+ # Score each type by keyword matches
123
+ scores: dict[str, int] = {}
124
+ for material_type, keywords in _MATERIAL_KEYWORDS.items():
125
+ score = sum(1 for kw in keywords if kw in lower)
126
+ if score > 0:
127
+ scores[material_type] = score
128
+
129
+ if not scores:
130
+ return "unknown"
131
+
132
+ return max(scores, key=scores.get)
133
+
134
+
135
+ # ── Simpler Mode Recommendation ────────────────────────────────────
136
+
137
+
138
+ def suggest_simpler_mode(profile: SampleProfile) -> str:
139
+ """Recommend Simpler playback mode based on material analysis.
140
+
141
+ Returns: "classic", "one_shot", or "slice"
142
+ """
143
+ if profile.duration_seconds < 0.5 or profile.material_type == "one_shot":
144
+ return "classic"
145
+ if profile.material_type == "fx":
146
+ return "classic"
147
+ if profile.material_type in ("texture", "foley"):
148
+ return "classic"
149
+ if profile.material_type in ("drum_loop", "instrument_loop",
150
+ "vocal", "full_mix"):
151
+ return "slice"
152
+ # Unknown material with decent length — slice is more useful
153
+ if profile.duration_seconds > 2.0:
154
+ return "slice"
155
+ return "classic"
156
+
157
+
158
+ def suggest_slice_method(profile: SampleProfile) -> str:
159
+ """Recommend slice-by method for Simpler's Slice mode."""
160
+ if profile.material_type == "drum_loop":
161
+ return "transient"
162
+ if profile.material_type == "instrument_loop":
163
+ return "beat"
164
+ if profile.material_type == "vocal":
165
+ return "region"
166
+ if profile.material_type == "full_mix":
167
+ return "beat"
168
+ return "transient"
169
+
170
+
171
+ def suggest_warp_mode(profile: SampleProfile) -> str:
172
+ """Recommend Ableton warp mode for the sample material."""
173
+ mode_map = {
174
+ "drum_loop": "beats",
175
+ "one_shot": "complex",
176
+ "instrument_loop": "complex_pro",
177
+ "vocal": "complex_pro",
178
+ "texture": "texture",
179
+ "foley": "texture",
180
+ "fx": "complex",
181
+ "full_mix": "complex_pro",
182
+ }
183
+ return mode_map.get(profile.material_type, "complex")
184
+
185
+
186
+ def build_profile_from_filename(
187
+ file_path: str,
188
+ source: str = "filesystem",
189
+ duration_seconds: float = 0.0,
190
+ ) -> SampleProfile:
191
+ """Build a SampleProfile from filename metadata only (no spectral analysis).
192
+
193
+ This is the fallback when M4L bridge is unavailable.
194
+ """
195
+ name = os.path.splitext(os.path.basename(file_path))[0]
196
+ metadata = parse_filename_metadata(file_path)
197
+ material = classify_material_from_name(name)
198
+
199
+ profile = SampleProfile(
200
+ source=source,
201
+ file_path=file_path,
202
+ name=name,
203
+ key=metadata.get("key"),
204
+ key_confidence=0.5 if metadata.get("key") else 0.0,
205
+ bpm=metadata.get("bpm"),
206
+ bpm_confidence=0.5 if metadata.get("bpm") else 0.0,
207
+ material_type=material,
208
+ material_confidence=0.4, # filename-only is low confidence
209
+ duration_seconds=duration_seconds,
210
+ )
211
+
212
+ profile.suggested_mode = suggest_simpler_mode(profile)
213
+ profile.suggested_slice_by = suggest_slice_method(profile)
214
+ profile.suggested_warp_mode = suggest_warp_mode(profile)
215
+
216
+ return profile
@@ -0,0 +1,390 @@
1
+ """Sample Engine critics — score sample fitness against the current song.
2
+
3
+ Six critics: key_fit, tempo_fit, frequency_fit, role_fit, vibe_fit, intent_fit.
4
+ All pure computation, zero I/O. Scores are 0.0-1.0 continuous (not issue-detection).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from .models import CriticResult, SampleProfile, SampleIntent
12
+
13
+
14
+ # ── Music Theory Helpers ────────────────────────────────────────────
15
+
16
+ _NOTE_TO_NUM = {
17
+ "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3,
18
+ "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8,
19
+ "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11,
20
+ }
21
+
22
+
23
+ def _parse_key_to_num(key_str: str) -> tuple[int, bool]:
24
+ """Parse key string to (pitch_class, is_minor)."""
25
+ if not key_str:
26
+ return (-1, False)
27
+ # Strip quality suffixes properly (not char-by-char rstrip)
28
+ s = key_str
29
+ is_minor = False
30
+ for suffix in ("minor", "min", "major", "maj"):
31
+ if s.endswith(suffix):
32
+ is_minor = suffix in ("minor", "min")
33
+ s = s[:-len(suffix)]
34
+ break
35
+ if s.endswith("m"):
36
+ is_minor = True
37
+ s = s[:-1]
38
+ root = s
39
+ num = _NOTE_TO_NUM.get(root, -1)
40
+ return (num, is_minor)
41
+
42
+
43
+ def _key_distance(key_a: str, key_b: str) -> int:
44
+ """Compute musical distance between two keys (0-6 on circle of fifths)."""
45
+ num_a, minor_a = _parse_key_to_num(key_a)
46
+ num_b, minor_b = _parse_key_to_num(key_b)
47
+ if num_a < 0 or num_b < 0:
48
+ return 7 # unknown
49
+
50
+ # Convert minor to relative major for comparison
51
+ if minor_a:
52
+ num_a = (num_a + 3) % 12
53
+ if minor_b:
54
+ num_b = (num_b + 3) % 12
55
+
56
+ # Circle of fifths distance
57
+ diff = (num_a - num_b) % 12
58
+ fifths = min(
59
+ _count_fifths(diff),
60
+ _count_fifths(12 - diff),
61
+ )
62
+ return fifths
63
+
64
+
65
+ def _count_fifths(semitones: int) -> int:
66
+ """Count steps on circle of fifths for a given semitone interval."""
67
+ # Map: 0->0, 7->1, 2->2, 9->3, 4->4, 11->5, 6->6
68
+ fifths_map = {0: 0, 7: 1, 2: 2, 9: 3, 4: 4, 11: 5, 6: 6,
69
+ 5: 1, 10: 2, 3: 3, 8: 4, 1: 5}
70
+ return fifths_map.get(semitones % 12, 6)
71
+
72
+
73
+ # ── Critics ─────────────────────────────────────────────────────────
74
+
75
+
76
+ def run_key_fit_critic(
77
+ profile: SampleProfile,
78
+ song_key: Optional[str] = None,
79
+ ) -> CriticResult:
80
+ """Score how well the sample's key fits the song."""
81
+ if profile.key is None:
82
+ return CriticResult(
83
+ critic_name="key_fit", score=0.0,
84
+ recommendation="Key unknown — verify by ear",
85
+ )
86
+ if song_key is None:
87
+ return CriticResult(
88
+ critic_name="key_fit", score=0.5,
89
+ recommendation="Song key unknown — cannot evaluate fit",
90
+ )
91
+
92
+ dist = _key_distance(profile.key, song_key)
93
+ # Score: 0 fifths = 1.0, 1 = 0.85, 2 = 0.7, 3 = 0.55, 4 = 0.4, 5+ = 0.3
94
+ score_map = {0: 1.0, 1: 0.85, 2: 0.7, 3: 0.55, 4: 0.4, 5: 0.3, 6: 0.25}
95
+ score = score_map.get(dist, 0.2)
96
+
97
+ if score >= 0.8:
98
+ rec = "Key matches well — load directly"
99
+ elif score >= 0.6:
100
+ rec = f"Closely related key — works for most intents"
101
+ elif score >= 0.4:
102
+ semitones = _suggest_transpose(profile.key, song_key)
103
+ rec = f"Distant key — transpose {semitones:+d} semitones or use as texture"
104
+ else:
105
+ rec = "Chromatic clash — use with heavy filtering or as intentional tension"
106
+
107
+ return CriticResult(critic_name="key_fit", score=score, recommendation=rec)
108
+
109
+
110
+ def _suggest_transpose(from_key: str, to_key: str) -> int:
111
+ """Suggest semitone transpose to match target key."""
112
+ num_from, _ = _parse_key_to_num(from_key)
113
+ num_to, _ = _parse_key_to_num(to_key)
114
+ if num_from < 0 or num_to < 0:
115
+ return 0
116
+ diff = (num_to - num_from) % 12
117
+ return diff if diff <= 6 else diff - 12
118
+
119
+
120
+ def run_tempo_fit_critic(
121
+ profile: SampleProfile,
122
+ session_tempo: float = 120.0,
123
+ ) -> CriticResult:
124
+ """Score how well the sample's BPM fits the session tempo."""
125
+ if profile.bpm is None:
126
+ return CriticResult(
127
+ critic_name="tempo_fit", score=0.0,
128
+ recommendation="BPM unknown — estimate from onsets or verify manually",
129
+ )
130
+
131
+ bpm = profile.bpm
132
+ # Check exact, half, double
133
+ ratios = [bpm / session_tempo, bpm / (session_tempo * 2), bpm / (session_tempo / 2)]
134
+ best_ratio = min(ratios, key=lambda r: abs(r - 1.0))
135
+ deviation = abs(best_ratio - 1.0)
136
+
137
+ if deviation < 0.01:
138
+ score, rec = 1.0, "Exact tempo match — no warping needed"
139
+ elif deviation < 0.02:
140
+ score, rec = 0.95, f"Near-exact match — minimal warping"
141
+ elif deviation < 0.05:
142
+ score, rec = 0.8, f"Within 5% — light warp preserves quality"
143
+ elif deviation < 0.10:
144
+ score, rec = 0.6, f"Within 10% — moderate warp, choose mode carefully"
145
+ elif deviation < 0.15:
146
+ score, rec = 0.4, f"Within 15% — significant warp, use Texture mode for ambient"
147
+ else:
148
+ score, rec = 0.2, f"Extreme tempo mismatch — use as texture, not rhythmically"
149
+
150
+ # Check if half/double time is the best match
151
+ if abs(bpm / session_tempo - 0.5) < 0.05:
152
+ score = max(score, 0.9)
153
+ rec = "Half-time match — set warp accordingly"
154
+ elif abs(bpm / session_tempo - 2.0) < 0.1:
155
+ score = max(score, 0.9)
156
+ rec = "Double-time match — set warp accordingly"
157
+
158
+ return CriticResult(critic_name="tempo_fit", score=score, recommendation=rec)
159
+
160
+
161
+ def run_frequency_fit_critic(
162
+ profile: SampleProfile,
163
+ mix_snapshot: Optional[dict] = None,
164
+ ) -> CriticResult:
165
+ """Score frequency fit against existing mix.
166
+
167
+ Without mix_snapshot (no M4L bridge), returns neutral 0.5.
168
+ """
169
+ if mix_snapshot is None or not mix_snapshot:
170
+ return CriticResult(
171
+ critic_name="frequency_fit", score=0.5,
172
+ recommendation="No spectral data — verify frequency fit by ear",
173
+ adjustments=[{"note": "stub — spectral overlap analysis not yet implemented"}],
174
+ )
175
+
176
+ # Basic frequency overlap check using mix_snapshot track data
177
+ # mix_snapshot expected shape: {"tracks": [{"name": ..., "peak_frequency": ...}]}
178
+ tracks = mix_snapshot.get("tracks", [])
179
+ if not tracks:
180
+ return CriticResult(
181
+ critic_name="frequency_fit", score=0.5,
182
+ recommendation="Mix snapshot has no track data",
183
+ )
184
+
185
+ # Use sample's frequency_center to check for crowding
186
+ sample_center = profile.frequency_center
187
+ if sample_center <= 0:
188
+ return CriticResult(
189
+ critic_name="frequency_fit", score=0.5,
190
+ recommendation="Sample has no spectral data — verify by ear",
191
+ )
192
+
193
+ # Count tracks with energy near the sample's center frequency
194
+ crowding = 0
195
+ for track in tracks:
196
+ track_peak = track.get("peak_frequency", 0)
197
+ if track_peak > 0 and abs(track_peak - sample_center) < sample_center * 0.3:
198
+ crowding += 1
199
+
200
+ if crowding == 0:
201
+ score, rec = 1.0, "Fills an empty frequency range — no overlap"
202
+ elif crowding == 1:
203
+ score, rec = 0.7, "Some frequency overlap — EQ carving recommended"
204
+ elif crowding == 2:
205
+ score, rec = 0.4, "Significant masking risk — aggressive filtering needed"
206
+ else:
207
+ score, rec = 0.2, "Heavy frequency crowding — use as texture only"
208
+
209
+ return CriticResult(critic_name="frequency_fit", score=score, recommendation=rec)
210
+
211
+
212
+ def run_role_fit_critic(
213
+ profile: SampleProfile,
214
+ existing_roles: Optional[list[str]] = None,
215
+ ) -> CriticResult:
216
+ """Score whether this sample fills a missing role in the song."""
217
+ if existing_roles is None:
218
+ return CriticResult(
219
+ critic_name="role_fit", score=0.5,
220
+ recommendation="No role data available",
221
+ )
222
+
223
+ # Map material types to roles they fill
224
+ role_map = {
225
+ "vocal": ["vocal", "voice", "melody"],
226
+ "drum_loop": ["drums", "percussion", "rhythm", "beat"],
227
+ "one_shot": ["drums", "percussion", "hit"],
228
+ "instrument_loop": ["synth", "keys", "guitar", "melody"],
229
+ "texture": ["texture", "pad", "ambient", "atmosphere"],
230
+ "foley": ["texture", "foley", "sfx"],
231
+ "fx": ["fx", "transition", "riser"],
232
+ "full_mix": [],
233
+ }
234
+
235
+ sample_roles = role_map.get(profile.material_type, [])
236
+ existing_lower = [r.lower() for r in existing_roles]
237
+
238
+ # Check for overlap
239
+ overlap = sum(1 for r in sample_roles if any(r in e for e in existing_lower))
240
+
241
+ if overlap == 0 and sample_roles:
242
+ score = 1.0
243
+ rec = f"Fills missing role — no existing {profile.material_type} in track"
244
+ elif overlap == 0:
245
+ score = 0.5
246
+ rec = "Material type unclear for role analysis"
247
+ elif sample_roles and overlap >= len(sample_roles) / 2:
248
+ score = 0.3
249
+ rec = f"Redundant — already have {', '.join(existing_lower[:3])}. Use as texture instead"
250
+ elif overlap < len(sample_roles):
251
+ score = 0.7
252
+ rec = "Some role overlap — complements existing elements"
253
+ else:
254
+ score = 0.3
255
+ rec = f"Redundant — already have {', '.join(existing_lower[:3])}. Use as texture instead"
256
+
257
+ return CriticResult(critic_name="role_fit", score=score, recommendation=rec)
258
+
259
+
260
+ def run_vibe_fit_critic(
261
+ profile: SampleProfile,
262
+ taste_graph: object = None,
263
+ ) -> CriticResult:
264
+ """Score vibe fit using TasteGraph if available.
265
+
266
+ Uses brightness + transient_density as an energy proxy and compares
267
+ against taste_graph.novelty_band:
268
+ high novelty_band → user likes intense/novel → high energy fits better
269
+ low novelty_band → user likes subtle/familiar → low energy fits better
270
+ """
271
+ if taste_graph is None or not hasattr(taste_graph, "evidence_count"):
272
+ return CriticResult(
273
+ critic_name="vibe_fit", score=0.5,
274
+ recommendation="No taste data — neutral score",
275
+ )
276
+
277
+ if taste_graph.evidence_count == 0:
278
+ return CriticResult(
279
+ critic_name="vibe_fit", score=0.5,
280
+ recommendation="No taste evidence yet — neutral score",
281
+ )
282
+
283
+ # Compute energy proxy from sample characteristics (0.0 - 1.0)
284
+ # brightness and transient_density are both 0.0-1.0 range
285
+ energy = (profile.brightness + profile.transient_density) / 2.0
286
+ energy = max(0.0, min(1.0, energy))
287
+
288
+ # Compare against novelty_band as taste proxy
289
+ novelty_band = getattr(taste_graph, "novelty_band", 0.5)
290
+ novelty_band = max(0.0, min(1.0, novelty_band))
291
+
292
+ # Score: how well sample energy aligns with user's novelty preference
293
+ # Perfect alignment = 1.0, maximum mismatch = 0.2
294
+ distance = abs(energy - novelty_band)
295
+ score = max(0.2, 1.0 - distance)
296
+
297
+ if score >= 0.8:
298
+ rec = "Vibe aligns well with taste profile"
299
+ elif score >= 0.6:
300
+ rec = "Reasonable vibe match — minor energy difference"
301
+ elif score >= 0.4:
302
+ rec = "Vibe mismatch — sample energy differs from taste preference"
303
+ else:
304
+ rec = "Strong vibe clash — consider processing to shift energy"
305
+
306
+ return CriticResult(critic_name="vibe_fit", score=score, recommendation=rec)
307
+
308
+
309
+ def run_intent_fit_critic(
310
+ profile: SampleProfile,
311
+ intent: Optional[SampleIntent] = None,
312
+ ) -> CriticResult:
313
+ """Score how well the material serves the stated intent."""
314
+ if intent is None:
315
+ return CriticResult(
316
+ critic_name="intent_fit", score=0.5,
317
+ recommendation="No intent specified",
318
+ )
319
+
320
+ # Intent-material compatibility matrix
321
+ compat: dict[str, dict[str, float]] = {
322
+ "rhythm": {
323
+ "drum_loop": 1.0, "one_shot": 0.9, "vocal": 0.6,
324
+ "instrument_loop": 0.5, "full_mix": 0.4,
325
+ "texture": 0.2, "foley": 0.5, "fx": 0.3,
326
+ },
327
+ "texture": {
328
+ "texture": 1.0, "foley": 0.8, "vocal": 0.6,
329
+ "drum_loop": 0.5, "instrument_loop": 0.6,
330
+ "one_shot": 0.4, "fx": 0.7, "full_mix": 0.5,
331
+ },
332
+ "layer": {
333
+ "instrument_loop": 1.0, "vocal": 0.8, "texture": 0.7,
334
+ "drum_loop": 0.6, "one_shot": 0.3, "foley": 0.4,
335
+ },
336
+ "melody": {
337
+ "instrument_loop": 1.0, "vocal": 0.9, "one_shot": 0.5,
338
+ "texture": 0.3, "drum_loop": 0.2,
339
+ },
340
+ "vocal": {
341
+ "vocal": 1.0, "instrument_loop": 0.3, "texture": 0.2,
342
+ },
343
+ "atmosphere": {
344
+ "texture": 1.0, "foley": 0.9, "vocal": 0.5,
345
+ "fx": 0.8, "full_mix": 0.4,
346
+ },
347
+ "transform": {
348
+ # Everything is transformable — alchemist territory
349
+ "vocal": 0.9, "drum_loop": 0.9, "instrument_loop": 0.9,
350
+ "one_shot": 0.8, "texture": 0.8, "foley": 0.8,
351
+ "fx": 0.7, "full_mix": 0.7,
352
+ },
353
+ }
354
+
355
+ intent_scores = compat.get(intent.intent_type, {})
356
+ score = intent_scores.get(profile.material_type, 0.4)
357
+
358
+ if score >= 0.8:
359
+ rec = f"Natural fit for {intent.intent_type}"
360
+ elif score >= 0.6:
361
+ rec = f"Works for {intent.intent_type} with some processing"
362
+ elif score >= 0.4:
363
+ rec = f"Creative use required for {intent.intent_type} — consider alchemist approach"
364
+ else:
365
+ rec = f"Unusual match — would need heavy transformation"
366
+
367
+ return CriticResult(critic_name="intent_fit", score=score, recommendation=rec)
368
+
369
+
370
+ # ── Composite Runner ────────────────────────────────────────────────
371
+
372
+
373
+ def run_all_sample_critics(
374
+ profile: SampleProfile,
375
+ intent: Optional[SampleIntent] = None,
376
+ song_key: Optional[str] = None,
377
+ session_tempo: float = 120.0,
378
+ existing_roles: Optional[list[str]] = None,
379
+ mix_snapshot: Optional[dict] = None,
380
+ taste_graph: object = None,
381
+ ) -> dict[str, CriticResult]:
382
+ """Run the full 6-critic battery. Returns dict keyed by critic name."""
383
+ return {
384
+ "key_fit": run_key_fit_critic(profile, song_key),
385
+ "tempo_fit": run_tempo_fit_critic(profile, session_tempo),
386
+ "frequency_fit": run_frequency_fit_critic(profile, mix_snapshot),
387
+ "role_fit": run_role_fit_critic(profile, existing_roles),
388
+ "vibe_fit": run_vibe_fit_critic(profile, taste_graph),
389
+ "intent_fit": run_intent_fit_critic(profile, intent),
390
+ }