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
|
@@ -34,6 +34,23 @@ def probe_capabilities(
|
|
|
34
34
|
"detail": "TCP 9878 connection active" if ableton_ok else "Not connected",
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
# 1b. Live version capabilities
|
|
38
|
+
live_version_str = "12.0.0"
|
|
39
|
+
if ableton_ok:
|
|
40
|
+
try:
|
|
41
|
+
info = ableton.send_command("get_session_info")
|
|
42
|
+
live_version_str = info.get("live_version", "12.0.0")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
from .live_version import LiveVersionCapabilities
|
|
46
|
+
version_caps = LiveVersionCapabilities.from_version_string(live_version_str)
|
|
47
|
+
report["live_version"] = {
|
|
48
|
+
"status": "ok",
|
|
49
|
+
"version": live_version_str,
|
|
50
|
+
"capability_tier": version_caps.capability_tier,
|
|
51
|
+
"features": version_caps.to_dict(),
|
|
52
|
+
}
|
|
53
|
+
|
|
37
54
|
# 2. Remote Script parity
|
|
38
55
|
from .remote_commands import REMOTE_COMMANDS
|
|
39
56
|
report["remote_script"] = {
|
|
@@ -45,8 +62,10 @@ def probe_capabilities(
|
|
|
45
62
|
# 3. M4L bridge
|
|
46
63
|
bridge_ok = False
|
|
47
64
|
if ctx is not None:
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
lifespan_context = getattr(ctx, "lifespan_context", {}) if hasattr(ctx, "lifespan_context") else {}
|
|
66
|
+
bridge = lifespan_context.get("m4l")
|
|
67
|
+
spectral = lifespan_context.get("spectral")
|
|
68
|
+
bridge_ok = bridge is not None and spectral is not None and getattr(spectral, "is_connected", False)
|
|
50
69
|
report["m4l_bridge"] = {
|
|
51
70
|
"status": "ok" if bridge_ok else "unavailable",
|
|
52
71
|
"detail": "UDP 9880 / OSC 9881 active" if bridge_ok else "Not connected — 30 analyzer tools unavailable",
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""MCP-side Live version capabilities model.
|
|
2
|
+
|
|
3
|
+
Pure data model — no I/O. Parses version info from get_session_info
|
|
4
|
+
responses and exposes feature flags for tool routing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class LiveVersionCapabilities:
|
|
14
|
+
"""Feature availability based on detected Live version."""
|
|
15
|
+
|
|
16
|
+
major: int = 12
|
|
17
|
+
minor: int = 0
|
|
18
|
+
patch: int = 0
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def from_version_string(cls, version_str: str) -> LiveVersionCapabilities:
|
|
22
|
+
"""Parse '12.3.6' into a capabilities instance."""
|
|
23
|
+
parts = version_str.split(".")
|
|
24
|
+
major = int(parts[0]) if len(parts) > 0 else 12
|
|
25
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
26
|
+
patch = int(parts[2]) if len(parts) > 2 else 0
|
|
27
|
+
return cls(major=major, minor=minor, patch=patch)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def from_session_info(cls, session_info: dict) -> LiveVersionCapabilities:
|
|
31
|
+
"""Extract version from get_session_info response.
|
|
32
|
+
|
|
33
|
+
Looks for 'live_version' field. Falls back to 12.0.0 if absent
|
|
34
|
+
(pre-upgrade Remote Script).
|
|
35
|
+
"""
|
|
36
|
+
version_str = session_info.get("live_version", "12.0.0")
|
|
37
|
+
return cls.from_version_string(version_str)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def _version_tuple(self) -> tuple[int, int, int]:
|
|
41
|
+
return (self.major, self.minor, self.patch)
|
|
42
|
+
|
|
43
|
+
# ── Feature flags ──────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def has_native_arrangement_clips(self) -> bool:
|
|
47
|
+
"""Track.create_midi_clip(start, length) — 12.1.10+"""
|
|
48
|
+
return self._version_tuple >= (12, 1, 10)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def has_display_value(self) -> bool:
|
|
52
|
+
"""DeviceParameter.display_value — 12.2+"""
|
|
53
|
+
return self._version_tuple >= (12, 2, 0)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def has_insert_device(self) -> bool:
|
|
57
|
+
"""Track.insert_device(name, index?) — 12.3+"""
|
|
58
|
+
return self._version_tuple >= (12, 3, 0)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def has_drum_rack_construction(self) -> bool:
|
|
62
|
+
"""insert_chain + DrumChain.in_note — 12.3+"""
|
|
63
|
+
return self._version_tuple >= (12, 3, 0)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def has_take_lanes(self) -> bool:
|
|
67
|
+
"""Take Lanes API — 12.2+"""
|
|
68
|
+
return self._version_tuple >= (12, 2, 0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def has_stem_separation(self) -> bool:
|
|
72
|
+
"""Stem separation via MFL — 12.3+"""
|
|
73
|
+
return self._version_tuple >= (12, 3, 0)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def has_replace_sample_native(self) -> bool:
|
|
77
|
+
"""SimplerDevice.replace_sample(path) — 12.4+"""
|
|
78
|
+
return self._version_tuple >= (12, 4, 0)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def capability_tier(self) -> str:
|
|
82
|
+
"""Human-readable tier: core | enhanced_arrangement | full_intelligence."""
|
|
83
|
+
if self._version_tuple >= (12, 3, 0):
|
|
84
|
+
return "full_intelligence"
|
|
85
|
+
elif self._version_tuple >= (12, 1, 10):
|
|
86
|
+
return "enhanced_arrangement"
|
|
87
|
+
else:
|
|
88
|
+
return "core"
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict:
|
|
91
|
+
"""Serialize for API responses and capability probes."""
|
|
92
|
+
return {
|
|
93
|
+
"version": f"{self.major}.{self.minor}.{self.patch}",
|
|
94
|
+
"capability_tier": self.capability_tier,
|
|
95
|
+
"native_arrangement_clips": self.has_native_arrangement_clips,
|
|
96
|
+
"display_value": self.has_display_value,
|
|
97
|
+
"insert_device": self.has_insert_device,
|
|
98
|
+
"drum_rack_construction": self.has_drum_rack_construction,
|
|
99
|
+
"take_lanes": self.has_take_lanes,
|
|
100
|
+
"stem_separation": self.has_stem_separation,
|
|
101
|
+
"replace_sample_native": self.has_replace_sample_native,
|
|
102
|
+
}
|
|
@@ -38,18 +38,23 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
|
|
|
38
38
|
"fire_scene", "set_scene_name", "set_scene_color", "set_scene_tempo",
|
|
39
39
|
"get_scene_matrix", "fire_scene_clips", "stop_all_clips",
|
|
40
40
|
"get_playing_clips",
|
|
41
|
-
# devices (
|
|
41
|
+
# devices (15)
|
|
42
42
|
"get_device_info", "get_device_parameters", "set_device_parameter",
|
|
43
43
|
"batch_set_parameters", "toggle_device", "delete_device",
|
|
44
44
|
"move_device", "load_device_by_uri", "find_and_load_device",
|
|
45
45
|
"set_simpler_playback_mode", "get_rack_chains", "set_chain_volume",
|
|
46
|
+
"insert_device", # 12.3+ native device insertion
|
|
47
|
+
"insert_rack_chain", # 12.3+ rack chain insertion
|
|
48
|
+
"set_drum_chain_note", # 12.3+ drum chain note assignment
|
|
46
49
|
# clip_automation (3)
|
|
47
50
|
"get_clip_automation", "set_clip_automation", "clear_clip_automation",
|
|
48
|
-
# browser (
|
|
51
|
+
# browser (6)
|
|
49
52
|
"get_browser_tree", "get_browser_items", "search_browser",
|
|
50
53
|
"load_browser_item", "get_device_presets",
|
|
51
|
-
#
|
|
54
|
+
"scan_browser_deep", # Atlas deep scan — returns full category tree
|
|
55
|
+
# arrangement (21)
|
|
52
56
|
"get_arrangement_clips", "create_arrangement_clip",
|
|
57
|
+
"create_native_arrangement_clip",
|
|
53
58
|
"add_arrangement_notes", "get_arrangement_notes",
|
|
54
59
|
"remove_arrangement_notes", "remove_arrangement_notes_by_id",
|
|
55
60
|
"modify_arrangement_notes", "duplicate_arrangement_notes",
|
|
@@ -57,7 +62,7 @@ REMOTE_COMMANDS: frozenset[str] = frozenset({
|
|
|
57
62
|
"set_arrangement_clip_name", "jump_to_time",
|
|
58
63
|
"capture_midi", "start_recording", "stop_recording",
|
|
59
64
|
"get_cue_points", "jump_to_cue", "toggle_cue_point",
|
|
60
|
-
"back_to_arranger",
|
|
65
|
+
"back_to_arranger", "force_arrangement",
|
|
61
66
|
# diagnostics (1)
|
|
62
67
|
"get_session_diagnostics",
|
|
63
68
|
# ping (built-in)
|
|
@@ -94,14 +94,19 @@ def get_session_kernel(
|
|
|
94
94
|
|
|
95
95
|
# Core: session info + capability state
|
|
96
96
|
session_info = ableton.send_command("get_session_info")
|
|
97
|
+
session_ok = isinstance(session_info, dict) and "error" not in session_info
|
|
97
98
|
|
|
98
99
|
analyzer_ok = False
|
|
100
|
+
analyzer_fresh = False
|
|
99
101
|
if spectral is not None:
|
|
100
102
|
analyzer_ok = spectral.is_connected
|
|
103
|
+
if analyzer_ok:
|
|
104
|
+
analyzer_fresh = spectral.get("spectrum") is not None
|
|
101
105
|
|
|
102
106
|
state = build_capability_state(
|
|
103
|
-
session_ok=
|
|
107
|
+
session_ok=session_ok,
|
|
104
108
|
analyzer_ok=analyzer_ok,
|
|
109
|
+
analyzer_fresh=analyzer_fresh,
|
|
105
110
|
memory_ok=True,
|
|
106
111
|
)
|
|
107
112
|
|
|
@@ -112,10 +117,19 @@ def get_session_kernel(
|
|
|
112
117
|
session_mem = []
|
|
113
118
|
|
|
114
119
|
try:
|
|
115
|
-
from .action_ledger import
|
|
116
|
-
ledger =
|
|
120
|
+
from .action_ledger import SessionLedger
|
|
121
|
+
ledger = ctx.lifespan_context.get("action_ledger")
|
|
122
|
+
if ledger is None:
|
|
123
|
+
ledger = SessionLedger()
|
|
124
|
+
ctx.lifespan_context["action_ledger"] = ledger
|
|
117
125
|
if ledger:
|
|
118
|
-
|
|
126
|
+
recent = ledger.get_recent_moves(limit=10)
|
|
127
|
+
ledger_summary = {
|
|
128
|
+
"total_moves": len(ledger._entries),
|
|
129
|
+
"memory_candidate_count": len(ledger.get_memory_candidates()),
|
|
130
|
+
"last_move": ledger.get_last_move().to_dict() if ledger.get_last_move() else None,
|
|
131
|
+
"recent_moves": [entry.to_dict() for entry in recent],
|
|
132
|
+
}
|
|
119
133
|
except Exception:
|
|
120
134
|
pass
|
|
121
135
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sample Engine — intelligence layer for sample discovery, analysis, and manipulation."""
|
|
@@ -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
|