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.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +73 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +56 -19
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +21 -4
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +5 -5
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +2 -2
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +452 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/tools.py +201 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/preview_studio/tools.py +4 -4
- package/mcp_server/runtime/capability_probe.py +21 -2
- package/mcp_server/runtime/execution_router.py +4 -0
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/remote_commands.py +9 -4
- package/mcp_server/runtime/tools.py +18 -4
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +442 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/sample_compilers.py +372 -0
- package/mcp_server/server.py +51 -0
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +85 -1
- package/package.json +12 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +236 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- 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
|