livepilot 1.9.24 → 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 (165) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +73 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +56 -19
  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 +5 -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/m4l_bridge.py +1 -0
  124. package/mcp_server/preview_studio/tools.py +4 -4
  125. package/mcp_server/runtime/capability_probe.py +21 -2
  126. package/mcp_server/runtime/execution_router.py +4 -0
  127. package/mcp_server/runtime/live_version.py +102 -0
  128. package/mcp_server/runtime/remote_commands.py +9 -4
  129. package/mcp_server/runtime/tools.py +18 -4
  130. package/mcp_server/sample_engine/__init__.py +1 -0
  131. package/mcp_server/sample_engine/analyzer.py +216 -0
  132. package/mcp_server/sample_engine/critics.py +390 -0
  133. package/mcp_server/sample_engine/models.py +193 -0
  134. package/mcp_server/sample_engine/moves.py +127 -0
  135. package/mcp_server/sample_engine/planner.py +186 -0
  136. package/mcp_server/sample_engine/sources.py +540 -0
  137. package/mcp_server/sample_engine/techniques.py +908 -0
  138. package/mcp_server/sample_engine/tools.py +442 -0
  139. package/mcp_server/semantic_moves/__init__.py +3 -0
  140. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  141. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  142. package/mcp_server/server.py +51 -0
  143. package/mcp_server/sound_design/critics.py +89 -1
  144. package/mcp_server/splice_client/__init__.py +1 -0
  145. package/mcp_server/splice_client/client.py +347 -0
  146. package/mcp_server/splice_client/models.py +96 -0
  147. package/mcp_server/splice_client/protos/__init__.py +1 -0
  148. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  149. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  150. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  151. package/mcp_server/tools/arrangement.py +69 -0
  152. package/mcp_server/tools/automation.py +15 -2
  153. package/mcp_server/tools/devices.py +117 -6
  154. package/mcp_server/tools/notes.py +37 -4
  155. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  156. package/mcp_server/wonder_mode/engine.py +85 -1
  157. package/package.json +12 -2
  158. package/remote_script/LivePilot/__init__.py +8 -1
  159. package/remote_script/LivePilot/arrangement.py +114 -0
  160. package/remote_script/LivePilot/browser.py +56 -1
  161. package/remote_script/LivePilot/devices.py +236 -6
  162. package/remote_script/LivePilot/mixing.py +8 -3
  163. package/remote_script/LivePilot/server.py +5 -1
  164. package/remote_script/LivePilot/transport.py +3 -0
  165. package/remote_script/LivePilot/version_detect.py +78 -0
@@ -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
+ }
@@ -0,0 +1,193 @@
1
+ """Sample Engine data models — all dataclasses with to_dict().
2
+
3
+ Pure data structures for sample profiles, intents, critic results,
4
+ fit reports, candidates, and techniques. Zero I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Optional
11
+
12
+
13
+ VALID_MATERIAL_TYPES = frozenset({
14
+ "vocal", "drum_loop", "instrument_loop", "one_shot",
15
+ "texture", "foley", "fx", "full_mix", "unknown",
16
+ })
17
+
18
+ VALID_INTENTS = frozenset({
19
+ "rhythm", "texture", "layer", "melody", "vocal",
20
+ "atmosphere", "transform", "challenge",
21
+ })
22
+
23
+ VALID_SIMPLER_MODES = frozenset({"classic", "one_shot", "slice"})
24
+
25
+ VALID_SLICE_METHODS = frozenset({"transient", "beat", "region", "manual"})
26
+
27
+ VALID_WARP_MODES = frozenset({
28
+ "beats", "tones", "texture", "complex", "complex_pro",
29
+ })
30
+
31
+
32
+ @dataclass
33
+ class SampleProfile:
34
+ """Complete fingerprint of a sample."""
35
+
36
+ source: str
37
+ file_path: str
38
+ name: str
39
+ uri: Optional[str] = None
40
+ freesound_id: Optional[int] = None
41
+ license: Optional[str] = None
42
+
43
+ key: Optional[str] = None
44
+ key_confidence: float = 0.0
45
+ bpm: Optional[float] = None
46
+ bpm_confidence: float = 0.0
47
+ time_signature: str = "4/4"
48
+
49
+ material_type: str = "unknown"
50
+ material_confidence: float = 0.0
51
+
52
+ frequency_center: float = 0.0
53
+ frequency_spread: float = 0.0
54
+ brightness: float = 0.0
55
+ transient_density: float = 0.0
56
+
57
+ duration_seconds: float = 0.0
58
+ duration_beats: Optional[float] = None
59
+ bar_count: Optional[float] = None
60
+ has_clear_downbeat: bool = False
61
+
62
+ suggested_mode: str = "classic"
63
+ suggested_slice_by: str = "transient"
64
+ suggested_warp_mode: str = "complex"
65
+
66
+ def to_dict(self) -> dict:
67
+ return asdict(self)
68
+
69
+
70
+ @dataclass
71
+ class SampleIntent:
72
+ """What the user wants to do with a sample."""
73
+
74
+ intent_type: str
75
+ description: str
76
+ philosophy: str = "auto"
77
+ target_track: Optional[int] = None
78
+
79
+ def to_dict(self) -> dict:
80
+ return asdict(self)
81
+
82
+
83
+ @dataclass
84
+ class CriticResult:
85
+ """Result from a single sample critic."""
86
+
87
+ critic_name: str
88
+ score: float
89
+ recommendation: str
90
+ adjustments: list = field(default_factory=list)
91
+
92
+ @property
93
+ def rating(self) -> str:
94
+ if self.score >= 0.8:
95
+ return "excellent"
96
+ if self.score >= 0.6:
97
+ return "good"
98
+ if self.score >= 0.4:
99
+ return "fair"
100
+ return "poor"
101
+
102
+ def to_dict(self) -> dict:
103
+ d = asdict(self)
104
+ d["rating"] = self.rating
105
+ return d
106
+
107
+
108
+ @dataclass
109
+ class SampleFitReport:
110
+ """Output of the 6-critic battery."""
111
+
112
+ sample: SampleProfile
113
+ critics: dict # str -> CriticResult
114
+ recommended_intent: str = ""
115
+ recommended_technique: str = ""
116
+ processing_chain: list = field(default_factory=list)
117
+ warnings: list = field(default_factory=list)
118
+ surgeon_plan: list = field(default_factory=list)
119
+ alchemist_plan: list = field(default_factory=list)
120
+
121
+ @property
122
+ def overall_score(self) -> float:
123
+ if not self.critics:
124
+ return 0.0
125
+ scores = [c.score if isinstance(c, CriticResult) else c.get("score", 0)
126
+ for c in self.critics.values()]
127
+ return sum(scores) / len(scores) if scores else 0.0
128
+
129
+ def to_dict(self) -> dict:
130
+ return {
131
+ "sample": self.sample.to_dict(),
132
+ "overall_score": round(self.overall_score, 3),
133
+ "critics": {k: (v.to_dict() if isinstance(v, CriticResult) else v)
134
+ for k, v in self.critics.items()},
135
+ "recommended_intent": self.recommended_intent,
136
+ "recommended_technique": self.recommended_technique,
137
+ "processing_chain": self.processing_chain,
138
+ "warnings": self.warnings,
139
+ "surgeon_plan": self.surgeon_plan,
140
+ "alchemist_plan": self.alchemist_plan,
141
+ }
142
+
143
+
144
+ @dataclass
145
+ class SampleCandidate:
146
+ """A sample discovered by a source, pre-load."""
147
+
148
+ source: str
149
+ name: str
150
+ metadata: dict = field(default_factory=dict)
151
+ file_path: Optional[str] = None
152
+ uri: Optional[str] = None
153
+ freesound_id: Optional[int] = None
154
+ relevance_score: float = 0.0
155
+
156
+ def to_dict(self) -> dict:
157
+ return asdict(self)
158
+
159
+
160
+ @dataclass
161
+ class TechniqueStep:
162
+ """A single step in a sample technique recipe."""
163
+
164
+ tool: str
165
+ params: dict = field(default_factory=dict)
166
+ description: str = ""
167
+ condition: Optional[str] = None
168
+
169
+ def to_dict(self) -> dict:
170
+ return asdict(self)
171
+
172
+
173
+ @dataclass
174
+ class SampleTechnique:
175
+ """A sample manipulation recipe from the technique library."""
176
+
177
+ technique_id: str
178
+ name: str
179
+ philosophy: str
180
+ material_types: list = field(default_factory=list)
181
+ intents: list = field(default_factory=list)
182
+ difficulty: str = "basic"
183
+ description: str = ""
184
+ inspiration: str = ""
185
+ steps: list = field(default_factory=list) # list[TechniqueStep]
186
+ success_signals: list = field(default_factory=list)
187
+ failure_signals: list = field(default_factory=list)
188
+
189
+ def to_dict(self) -> dict:
190
+ d = asdict(self)
191
+ d["steps"] = [s.to_dict() if isinstance(s, TechniqueStep) else s
192
+ for s in self.steps]
193
+ return d