livepilot 1.23.6 → 1.24.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/CHANGELOG.md +37 -0
- package/README.md +59 -13
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +17 -3
- package/mcp_server/audit/__init__.py +6 -0
- package/mcp_server/audit/checks.py +618 -0
- package/mcp_server/audit/tools.py +232 -0
- package/mcp_server/composer/branch_producer.py +5 -2
- package/mcp_server/composer/develop/__init__.py +19 -0
- package/mcp_server/composer/develop/apply.py +217 -0
- package/mcp_server/composer/develop/brief_builder.py +269 -0
- package/mcp_server/composer/develop/seed_introspector.py +195 -0
- package/mcp_server/composer/engine.py +15 -521
- package/mcp_server/composer/fast/__init__.py +62 -0
- package/mcp_server/composer/fast/apply.py +533 -0
- package/mcp_server/composer/fast/brief_builder.py +1479 -0
- package/mcp_server/composer/fast/tier_classification.py +159 -0
- package/mcp_server/composer/framework/__init__.py +0 -0
- package/mcp_server/composer/framework/applier.py +179 -0
- package/mcp_server/composer/framework/artist_loader.py +63 -0
- package/mcp_server/composer/framework/brief.py +79 -0
- package/mcp_server/composer/framework/event_lexicon.py +71 -0
- package/mcp_server/composer/framework/genre_loader.py +77 -0
- package/mcp_server/composer/framework/intent_source.py +137 -0
- package/mcp_server/composer/framework/knowledge_pack.py +49 -0
- package/mcp_server/composer/framework/plan_compiler.py +10 -0
- package/mcp_server/composer/full/__init__.py +10 -0
- package/mcp_server/composer/full/apply.py +1139 -0
- package/mcp_server/composer/full/brief_builder.py +144 -0
- package/mcp_server/composer/full/engine.py +541 -0
- package/mcp_server/composer/full/layer_planner.py +491 -0
- package/mcp_server/composer/layer_planner.py +19 -465
- package/mcp_server/composer/sample_resolver.py +80 -7
- package/mcp_server/composer/tools.py +626 -28
- package/mcp_server/server.py +1 -0
- package/mcp_server/splice_client/client.py +7 -0
- package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
- package/mcp_server/tools/_planner_engine.py +25 -63
- package/mcp_server/tools/analyzer.py +10 -4
- package/package.json +2 -2
- package/remote_script/LivePilot/__init__.py +1 -1
- package/server.json +3 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Tier classification for instruments — used by fast-mode brief hunt-order.
|
|
2
|
+
|
|
3
|
+
The framework provides VOCABULARY (descriptive). The LLM provides FORM (creative).
|
|
4
|
+
For instrument candidates: Tier-A and Tier-B are safe to return in briefs.
|
|
5
|
+
Tier-C bare URIs MUST NEVER be returned — the brief substitutes a curated preset
|
|
6
|
+
or omits that synth entirely.
|
|
7
|
+
|
|
8
|
+
Tier table (BINDING):
|
|
9
|
+
A_curated_preset — .adg / .adv from sounds/ folder. Loaded with character.
|
|
10
|
+
A_drum_sample — raw sample (.aif/.wav) from drums/ folder. load_browser_item
|
|
11
|
+
auto-wraps in Simpler with drum-role defaults.
|
|
12
|
+
B_drum_synth — drum-specific synth (DS Kick etc). Default patch IS the drum.
|
|
13
|
+
B_audible_default — generic melodic synth (Operator, Wavetable etc).
|
|
14
|
+
Default patch is "generic AI synth" — only return as fallback
|
|
15
|
+
when sounds/ returns nothing for a melodic role.
|
|
16
|
+
C_needs_preset — empty container (Drum Sampler, Emit etc). NEVER return.
|
|
17
|
+
|
|
18
|
+
Legacy tier value "A_sample_ready" is accepted in VALID_BRIEF_TIERS so old
|
|
19
|
+
tests still pass; new candidates use the specific A_* values.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Tier VALUE constants (used as the `tier` field in brief candidates) ──
|
|
28
|
+
|
|
29
|
+
TIER_A_CURATED_PRESET = "A_curated_preset"
|
|
30
|
+
TIER_A_DRUM_SAMPLE = "A_drum_sample"
|
|
31
|
+
TIER_B_DRUM_SYNTH = "B_drum_synth"
|
|
32
|
+
TIER_B_AUDIBLE_DEFAULT_VALUE = "B_audible_default"
|
|
33
|
+
TIER_C_NEEDS_PRESET_VALUE = "C_needs_preset"
|
|
34
|
+
|
|
35
|
+
# Tiers that are valid in brief output (Tier-C is NEVER valid)
|
|
36
|
+
VALID_BRIEF_TIERS = frozenset({
|
|
37
|
+
TIER_A_CURATED_PRESET,
|
|
38
|
+
TIER_A_DRUM_SAMPLE,
|
|
39
|
+
TIER_B_DRUM_SYNTH,
|
|
40
|
+
TIER_B_AUDIBLE_DEFAULT_VALUE,
|
|
41
|
+
# Legacy alias — kept so old briefs and tests still work
|
|
42
|
+
"A_sample_ready",
|
|
43
|
+
# Legacy alias — "B_audible_default" is also the old tier value and is valid
|
|
44
|
+
"B_audible_default",
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
# Role sets
|
|
48
|
+
DRUM_ROLES = frozenset({"kick", "snare", "hat", "perc", "clap", "tom", "drum"})
|
|
49
|
+
MELODIC_ROLES = frozenset({"bass", "lead", "pad", "atmos", "vox", "fx", "texture"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Frozensets of instrument names (used for name-based classification) ──
|
|
53
|
+
|
|
54
|
+
# Drum-specific synths — purpose-built drum sound generators.
|
|
55
|
+
# Their default patches ARE the intended drum sound (unlike generic melodic
|
|
56
|
+
# synths whose defaults are "generic AI synth").
|
|
57
|
+
# Allowed in drum-role briefs as Tier-B without curated presets.
|
|
58
|
+
DRUM_SPECIFIC_SYNTHS: frozenset[str] = frozenset({
|
|
59
|
+
"DS Kick",
|
|
60
|
+
"DS Snare",
|
|
61
|
+
"DS Hi-Hat",
|
|
62
|
+
"DS Clap",
|
|
63
|
+
"DS Cymbal",
|
|
64
|
+
"DS Tom",
|
|
65
|
+
"DS Sampler",
|
|
66
|
+
"DS Drum Bus",
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
# Melodic synths with audible defaults — "generic AI synth" risk.
|
|
70
|
+
# Renamed from TIER_B_AUDIBLE_DEFAULT to MELODIC_AUDIBLE_DEFAULTS to
|
|
71
|
+
# disambiguate from the tier VALUE string TIER_B_AUDIBLE_DEFAULT_VALUE.
|
|
72
|
+
# The old name TIER_B_AUDIBLE_DEFAULT is kept as an alias for backward compat.
|
|
73
|
+
MELODIC_AUDIBLE_DEFAULTS: frozenset[str] = frozenset({
|
|
74
|
+
"Operator",
|
|
75
|
+
"Wavetable",
|
|
76
|
+
"Drift",
|
|
77
|
+
"Analog",
|
|
78
|
+
"Bass",
|
|
79
|
+
"Electric",
|
|
80
|
+
"Tension",
|
|
81
|
+
"Collision",
|
|
82
|
+
"Meld",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# Backward-compat alias (old tests and callers use this name)
|
|
86
|
+
TIER_B_AUDIBLE_DEFAULT = MELODIC_AUDIBLE_DEFAULTS
|
|
87
|
+
|
|
88
|
+
# Containers + programming-required synths — NEVER return bare URIs.
|
|
89
|
+
# Renamed from TIER_C_NEEDS_PRESET to CONTAINERS_NEEDING_PRESETS to
|
|
90
|
+
# disambiguate from the tier VALUE string TIER_C_NEEDS_PRESET_VALUE.
|
|
91
|
+
# The old name TIER_C_NEEDS_PRESET is kept as an alias for backward compat.
|
|
92
|
+
CONTAINERS_NEEDING_PRESETS: frozenset[str] = frozenset({
|
|
93
|
+
"Drum Sampler",
|
|
94
|
+
"Drum Rack",
|
|
95
|
+
"DrumGroup", # internal alias for Drum Rack
|
|
96
|
+
"Simpler",
|
|
97
|
+
"Sampler",
|
|
98
|
+
"Impulse",
|
|
99
|
+
"Emit",
|
|
100
|
+
"Vector FM",
|
|
101
|
+
"Vector Grain",
|
|
102
|
+
"Granulator III",
|
|
103
|
+
"Granulator II", # legacy
|
|
104
|
+
"Instrument Rack",
|
|
105
|
+
"Looper",
|
|
106
|
+
"External Instrument",
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
# Backward-compat alias
|
|
110
|
+
TIER_C_NEEDS_PRESET = CONTAINERS_NEEDING_PRESETS
|
|
111
|
+
|
|
112
|
+
# Combined lookup: name → tier string
|
|
113
|
+
# Drum-specific synths → "B_drum_synth"; melodic audible defaults → "B_audible_default";
|
|
114
|
+
# containers → "C_needs_preset". Unknown instruments return None.
|
|
115
|
+
TIER_CLASSIFICATION: dict[str, str] = {
|
|
116
|
+
name: "B_drum_synth" for name in DRUM_SPECIFIC_SYNTHS
|
|
117
|
+
} | {
|
|
118
|
+
name: "B_audible_default" for name in MELODIC_AUDIBLE_DEFAULTS
|
|
119
|
+
} | {
|
|
120
|
+
name: "C_needs_preset" for name in CONTAINERS_NEEDING_PRESETS
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def classify_instrument(name: str) -> Optional[str]:
|
|
125
|
+
"""Classify an instrument by name.
|
|
126
|
+
|
|
127
|
+
Returns one of:
|
|
128
|
+
"B_drum_synth" — drum-specific synth, safe for drum roles bare
|
|
129
|
+
"B_audible_default" — generic melodic synth, only as fallback
|
|
130
|
+
"C_needs_preset" — container, NEVER return bare
|
|
131
|
+
None — unknown instrument
|
|
132
|
+
"""
|
|
133
|
+
return TIER_CLASSIFICATION.get(name)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_drum_specific_synth(name: str) -> bool:
|
|
137
|
+
"""True if `name` is a drum-specific synth — safe to return bare for drum roles."""
|
|
138
|
+
return name in DRUM_SPECIFIC_SYNTHS
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Search terms for hunting curated chains in sounds/ and drums/ per role.
|
|
142
|
+
# Used by build_creative_brief to populate instruments_by_role.
|
|
143
|
+
ROLE_SEARCH_TERMS: dict[str, dict[str, Optional[str]]] = {
|
|
144
|
+
# drum roles — search drums/ first, sounds/ second
|
|
145
|
+
"kick": {"sounds_term": "kick", "drums_term": "kick"},
|
|
146
|
+
"snare": {"sounds_term": "snare", "drums_term": "snare"},
|
|
147
|
+
"hat": {"sounds_term": "hihat", "drums_term": "hihat"},
|
|
148
|
+
"perc": {"sounds_term": "percussion", "drums_term": "perc"},
|
|
149
|
+
"clap": {"sounds_term": "clap", "drums_term": "clap"},
|
|
150
|
+
"tom": {"sounds_term": "tom", "drums_term": "tom"},
|
|
151
|
+
# melodic roles — search sounds/ for curated presets
|
|
152
|
+
"bass": {"sounds_term": "bass", "drums_term": None},
|
|
153
|
+
"lead": {"sounds_term": "lead", "drums_term": None},
|
|
154
|
+
"pad": {"sounds_term": "pad", "drums_term": None},
|
|
155
|
+
"atmos": {"sounds_term": "ambient", "drums_term": None},
|
|
156
|
+
"vox": {"sounds_term": "vocal", "drums_term": None},
|
|
157
|
+
"fx": {"sounds_term": "fx", "drums_term": None},
|
|
158
|
+
"texture": {"sounds_term": "texture", "drums_term": None},
|
|
159
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Applier — shared Phase-3 executor for fast / full / develop compose modes.
|
|
2
|
+
|
|
3
|
+
Concentrates pre-flight (analyzer load + bridge connect + handshake retry)
|
|
4
|
+
and post-flight (monitoring state + back_to_arranger) so fixes for
|
|
5
|
+
BUG-FULL-MODE-14 (bridge race) and BUG-FULL-MODE-17 (manual arm required)
|
|
6
|
+
land once across all three modes instead of three times.
|
|
7
|
+
|
|
8
|
+
Functions are dependency-injected (not imported directly) so unit tests can
|
|
9
|
+
mock them without monkey-patching.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Awaitable, Callable, Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Type aliases
|
|
22
|
+
AsyncCtxFn = Callable[[Any], Awaitable[Any]]
|
|
23
|
+
AsyncTrackFn = Callable[..., Awaitable[Any]]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Applier:
|
|
27
|
+
"""Shared pre-flight + apply + post-flight skeleton for compose modes.
|
|
28
|
+
|
|
29
|
+
v1.24 Phase 1: pre-flight + post-flight only. The `run()` method (full
|
|
30
|
+
plan execution) stays a stub; each mode's apply.py wires its own
|
|
31
|
+
plan-execution loop using `Applier.preflight()` and `Applier.postflight()`
|
|
32
|
+
around it.
|
|
33
|
+
|
|
34
|
+
Future phases may extend this with a unified plan-walker.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
ensure_analyzer_fn: AsyncCtxFn,
|
|
41
|
+
reconnect_bridge_fn: AsyncCtxFn,
|
|
42
|
+
bridge_ping_fn: AsyncCtxFn,
|
|
43
|
+
set_track_input_monitoring_fn: Optional[AsyncTrackFn] = None,
|
|
44
|
+
back_to_arranger_fn: Optional[AsyncCtxFn] = None,
|
|
45
|
+
handshake_max_attempts: int = 3,
|
|
46
|
+
handshake_gap_seconds: float = 0.2,
|
|
47
|
+
):
|
|
48
|
+
self._ensure_analyzer_fn = ensure_analyzer_fn
|
|
49
|
+
self._reconnect_bridge_fn = reconnect_bridge_fn
|
|
50
|
+
self._bridge_ping_fn = bridge_ping_fn
|
|
51
|
+
self._set_track_input_monitoring_fn = set_track_input_monitoring_fn
|
|
52
|
+
self._back_to_arranger_fn = back_to_arranger_fn
|
|
53
|
+
self._handshake_max_attempts = handshake_max_attempts
|
|
54
|
+
self._handshake_gap_seconds = handshake_gap_seconds
|
|
55
|
+
|
|
56
|
+
# ── pre-flight ──────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
async def preflight(self, ctx: Any) -> dict:
|
|
59
|
+
"""Load analyzer, connect bridge, handshake until ping succeeds.
|
|
60
|
+
|
|
61
|
+
Fixes BUG-FULL-MODE-14: the previous code returned success on
|
|
62
|
+
bridge.connect() but the M4L JS listener takes 100-500ms to bind
|
|
63
|
+
the UDP socket. Without the handshake retry loop, the next
|
|
64
|
+
bridge-using call (load_sample_to_simpler etc.) fails with
|
|
65
|
+
"UDP bridge is not connected".
|
|
66
|
+
|
|
67
|
+
Returns dict with keys: analyzer_status, bridge_connected,
|
|
68
|
+
handshake_attempts, and optionally handshake_error if all
|
|
69
|
+
retries failed.
|
|
70
|
+
"""
|
|
71
|
+
analyzer_result = await self._ensure_analyzer_fn(ctx)
|
|
72
|
+
analyzer_status = (
|
|
73
|
+
analyzer_result.get("status", "unknown")
|
|
74
|
+
if isinstance(analyzer_result, dict)
|
|
75
|
+
else "unknown"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
bridge_result = await self._reconnect_bridge_fn(ctx)
|
|
79
|
+
bridge_connected_initial = (
|
|
80
|
+
bridge_result.get("connected", False)
|
|
81
|
+
if isinstance(bridge_result, dict)
|
|
82
|
+
else False
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
handshake_attempts = 0
|
|
86
|
+
handshake_error: Optional[str] = None
|
|
87
|
+
bridge_ready = False
|
|
88
|
+
|
|
89
|
+
if bridge_connected_initial:
|
|
90
|
+
for attempt in range(1, self._handshake_max_attempts + 1):
|
|
91
|
+
handshake_attempts = attempt
|
|
92
|
+
try:
|
|
93
|
+
await self._bridge_ping_fn(ctx)
|
|
94
|
+
bridge_ready = True
|
|
95
|
+
break
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
handshake_error = str(exc)
|
|
98
|
+
logger.debug(
|
|
99
|
+
"Applier preflight handshake attempt %d failed: %s",
|
|
100
|
+
attempt,
|
|
101
|
+
exc,
|
|
102
|
+
)
|
|
103
|
+
if attempt < self._handshake_max_attempts:
|
|
104
|
+
await asyncio.sleep(self._handshake_gap_seconds)
|
|
105
|
+
|
|
106
|
+
result = {
|
|
107
|
+
"analyzer_status": analyzer_status,
|
|
108
|
+
"bridge_connected": bridge_ready,
|
|
109
|
+
"handshake_attempts": handshake_attempts,
|
|
110
|
+
}
|
|
111
|
+
if not bridge_ready and handshake_error is not None:
|
|
112
|
+
result["handshake_error"] = handshake_error
|
|
113
|
+
return result
|
|
114
|
+
|
|
115
|
+
# ── post-flight ─────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
async def postflight(
|
|
118
|
+
self,
|
|
119
|
+
ctx: Any,
|
|
120
|
+
applied_track_indices: list[int],
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Set monitoring=Auto on each newly-created track, then back_to_arranger.
|
|
123
|
+
|
|
124
|
+
BUG-FIX (post-Phase-4-Task-4 live test): the original BUG-FULL-MODE-17
|
|
125
|
+
fix set monitoring to state=0 ("In"), which is WRONG. State codes:
|
|
126
|
+
0 = In (always pass input through — leaves track "hot")
|
|
127
|
+
1 = Auto (monitor when armed + recording — DEFAULT for new tracks)
|
|
128
|
+
2 = Off (never monitor)
|
|
129
|
+
|
|
130
|
+
New tracks default to Auto (1) and arrangement clips already play
|
|
131
|
+
correctly with Auto. The actual fix for "manual arm required" is
|
|
132
|
+
back_to_arranger alone — clearing the session-vs-arrangement
|
|
133
|
+
override flag. We set state=Auto here defensively in case any
|
|
134
|
+
other code path moved monitoring away from default.
|
|
135
|
+
|
|
136
|
+
applied_track_indices: list of track indices that were created
|
|
137
|
+
during this apply pass. Empty list (e.g. develop mode writing
|
|
138
|
+
only session clips, no new tracks) skips the per-track step but
|
|
139
|
+
still calls back_to_arranger.
|
|
140
|
+
"""
|
|
141
|
+
tracks_set = 0
|
|
142
|
+
if self._set_track_input_monitoring_fn is not None:
|
|
143
|
+
for track_index in applied_track_indices:
|
|
144
|
+
try:
|
|
145
|
+
# state=1 (Auto) — the default for new tracks. NOT 0 (In).
|
|
146
|
+
await self._set_track_input_monitoring_fn(
|
|
147
|
+
ctx, track_index=track_index, state=1
|
|
148
|
+
)
|
|
149
|
+
tracks_set += 1
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
logger.warning(
|
|
152
|
+
"Applier postflight set_monitoring failed for track %d: %s",
|
|
153
|
+
track_index,
|
|
154
|
+
exc,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
back_to_arranger_ok = False
|
|
158
|
+
if self._back_to_arranger_fn is not None:
|
|
159
|
+
try:
|
|
160
|
+
await self._back_to_arranger_fn(ctx)
|
|
161
|
+
back_to_arranger_ok = True
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logger.warning("Applier postflight back_to_arranger failed: %s", exc)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"tracks_set": tracks_set,
|
|
167
|
+
"back_to_arranger": back_to_arranger_ok,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# ── stub run() — not used in v1.24 ──────────────────────────────
|
|
171
|
+
|
|
172
|
+
async def run(self, ctx: Any, plan: list) -> dict:
|
|
173
|
+
"""Stub kept from Task 1 — full plan-walk implementation deferred.
|
|
174
|
+
|
|
175
|
+
Phase 1 only extracts pre/post-flight. Each mode's apply.py
|
|
176
|
+
continues to execute its own plan loop, just sandwiched by
|
|
177
|
+
``preflight()`` and ``postflight()``.
|
|
178
|
+
"""
|
|
179
|
+
return {"status": "stub"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Markdown parser for livepilot/skills/livepilot-core/references/artist-vocabularies.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_ARTIST_CACHE: Optional[dict[str, dict]] = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _artist_md_path() -> Optional[Path]:
|
|
13
|
+
here = Path(__file__).resolve()
|
|
14
|
+
for parent in here.parents:
|
|
15
|
+
candidate = parent / "livepilot" / "skills" / "livepilot-core" / "references" / "artist-vocabularies.md"
|
|
16
|
+
if candidate.exists():
|
|
17
|
+
return candidate
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_artist_md() -> dict[str, dict]:
|
|
22
|
+
"""Parse artist entries. Existing pattern in develop/brief_builder.py:
|
|
23
|
+
`### Name` headers with body content."""
|
|
24
|
+
global _ARTIST_CACHE
|
|
25
|
+
if _ARTIST_CACHE is not None:
|
|
26
|
+
return _ARTIST_CACHE
|
|
27
|
+
path = _artist_md_path()
|
|
28
|
+
if path is None:
|
|
29
|
+
_ARTIST_CACHE = {}
|
|
30
|
+
return _ARTIST_CACHE
|
|
31
|
+
text = path.read_text(encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
artists: dict[str, dict] = {}
|
|
34
|
+
sections = re.split(r"^###\s+(.+?)\s*$", text, flags=re.MULTILINE)
|
|
35
|
+
for i in range(1, len(sections), 2):
|
|
36
|
+
name = sections[i].strip()
|
|
37
|
+
if not name or "Vocabulary" in name or "How to" in name:
|
|
38
|
+
continue
|
|
39
|
+
body = sections[i + 1] if i + 1 < len(sections) else ""
|
|
40
|
+
# Strip parenthetical aliases for canonical key: "Aphex Twin (Richard D. James)" → "Aphex Twin"
|
|
41
|
+
primary = re.sub(r"\s*\(.*?\)\s*$", "", name).strip()
|
|
42
|
+
if primary:
|
|
43
|
+
artists[primary] = {"name": primary, "raw_md": body.strip()[:2000]}
|
|
44
|
+
_ARTIST_CACHE = artists
|
|
45
|
+
return _ARTIST_CACHE
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_artist_context(artist_names: list[str]) -> dict[str, dict]:
|
|
49
|
+
"""Load context for the given artist names.
|
|
50
|
+
|
|
51
|
+
artist_names: list of names (case-insensitive matched against parser keys)
|
|
52
|
+
Returns: {artist_name: {raw_md: ..., ...}, ...}
|
|
53
|
+
"""
|
|
54
|
+
if not artist_names:
|
|
55
|
+
return {}
|
|
56
|
+
parsed = _load_artist_md()
|
|
57
|
+
result = {}
|
|
58
|
+
parsed_lower = {k.lower(): k for k in parsed.keys()}
|
|
59
|
+
for name in artist_names:
|
|
60
|
+
canonical = parsed_lower.get(name.lower())
|
|
61
|
+
if canonical:
|
|
62
|
+
result[canonical] = parsed[canonical]
|
|
63
|
+
return result
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Brief dataclasses — uniform shape across the 3 compose modes (fast/full/develop).
|
|
2
|
+
|
|
3
|
+
The framework provides VOCABULARY (descriptive). The LLM provides FORM (creative).
|
|
4
|
+
Briefs MUST NOT carry predetermined section sequences, bar counts, or variant taxonomies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, asdict, fields
|
|
10
|
+
from typing import Optional, Type, TypeVar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
T = TypeVar("T", bound="Brief")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Brief:
|
|
18
|
+
"""Base — every compose mode returns a Brief subclass.
|
|
19
|
+
|
|
20
|
+
Common fields shared across all modes. Subclasses extend with mode-
|
|
21
|
+
specific fields (instruments_by_role, design_targets, seed_state, etc.).
|
|
22
|
+
"""
|
|
23
|
+
mode: str
|
|
24
|
+
tempo: float
|
|
25
|
+
key: Optional[str]
|
|
26
|
+
intent: dict
|
|
27
|
+
knowledge: dict
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict:
|
|
30
|
+
"""Serialize to a JSON-friendly dict including subclass fields."""
|
|
31
|
+
return asdict(self)
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_dict(cls: Type[T], data: dict) -> T:
|
|
35
|
+
"""Deserialize from a dict.
|
|
36
|
+
|
|
37
|
+
Filters out unknown keys (forward-compatibility) and uses dataclass
|
|
38
|
+
defaults for missing optional fields.
|
|
39
|
+
"""
|
|
40
|
+
known = {f.name for f in fields(cls)}
|
|
41
|
+
filtered = {k: v for k, v in data.items() if k in known}
|
|
42
|
+
return cls(**filtered)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class FastBrief(Brief):
|
|
47
|
+
"""Loop-sketch brief — agent designs MIDI inline.
|
|
48
|
+
|
|
49
|
+
instruments_by_role: dict mapping role name → list of atlas device candidates
|
|
50
|
+
scale_pitches: list of MIDI pitches in the inferred key/scale
|
|
51
|
+
"""
|
|
52
|
+
instruments_by_role: dict = field(default_factory=dict)
|
|
53
|
+
scale_pitches: list = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class FullBrief(Brief):
|
|
58
|
+
"""Full-track brief — agent designs FORM and notes per call.
|
|
59
|
+
|
|
60
|
+
NB: must NOT include section_sequence, bar_counts, or form_template
|
|
61
|
+
fields per the vocabulary-not-form principle.
|
|
62
|
+
|
|
63
|
+
research_hooks: list of niche-style terms the agent should research
|
|
64
|
+
before designing (WebSearch directives)
|
|
65
|
+
design_targets: open-ended description of what the agent should produce
|
|
66
|
+
"""
|
|
67
|
+
research_hooks: list = field(default_factory=list)
|
|
68
|
+
design_targets: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class DevelopBrief(Brief):
|
|
73
|
+
"""Loop-extension brief — agent designs variants per call.
|
|
74
|
+
|
|
75
|
+
seed_state: introspected SeedState dict (key, tempo, role classification, motif)
|
|
76
|
+
identity_preservation_directive: explicit guidance that seed must survive
|
|
77
|
+
"""
|
|
78
|
+
seed_state: dict = field(default_factory=dict)
|
|
79
|
+
identity_preservation_directive: str = ""
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""42-event structural lexicon — vocabulary of named primitives the agent
|
|
2
|
+
can schedule at phrase boundaries. Used by full + develop mode briefs.
|
|
3
|
+
|
|
4
|
+
Categories: filter, drums, layer, riser, dynamics, harmonic, space.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
EVENT_LEXICON: list[dict] = [
|
|
12
|
+
# Filter gestures (5)
|
|
13
|
+
{"name": "hpf_sweep_up", "category": "filter", "description": "HPF rises 80 Hz → 1-2 kHz over 16-32 bars before drop"},
|
|
14
|
+
{"name": "lpf_close_slow", "category": "filter", "description": "LPF closes gradually over 64-128 bars (ambient texture removal)"},
|
|
15
|
+
{"name": "filter_open_snap", "category": "filter", "description": "Sudden filter opening on bar 1 of new section (drop arrival)"},
|
|
16
|
+
{"name": "band_pass_sweep", "category": "filter", "description": "Band-pass center sweeps up over 8 bars (microhouse)"},
|
|
17
|
+
{"name": "filter_wobble_increase", "category": "filter", "description": "LFO-driven filter mod depth increases over 8 bars"},
|
|
18
|
+
# Drum / Rhythm events (11)
|
|
19
|
+
{"name": "kick_dropout", "category": "drums", "description": "Kick muted for 8-16 bars before reintroduction"},
|
|
20
|
+
{"name": "kick_first_entry", "category": "drums", "description": "Kick appears for the first time after percussion-only intro"},
|
|
21
|
+
{"name": "snare_roll_final_bars", "category": "drums", "description": "Snare roll accelerating over bars 15-16 of 16-bar phrase"},
|
|
22
|
+
{"name": "hat_layer_entry", "category": "drums", "description": "Additional hat layer enters (closed → open, or second hat sample)"},
|
|
23
|
+
{"name": "perc_dropout", "category": "drums", "description": "All percussion except kick removed for 4-8 bars (tension)"},
|
|
24
|
+
{"name": "full_drum_dropout", "category": "drums", "description": "All drums including kick removed (breakdown)"},
|
|
25
|
+
{"name": "drum_reintroduction", "category": "drums", "description": "Drums return after full dropout at full energy"},
|
|
26
|
+
{"name": "clap_add", "category": "drums", "description": "Clap or snare enters where only kick existed before"},
|
|
27
|
+
{"name": "ghost_density_increase", "category": "drums", "description": "Ghost note velocity raised across all hats"},
|
|
28
|
+
{"name": "and_of_4_snare", "category": "drums", "description": "Extra snare hit on 'and of 4' on bar 16 (common fill marker)"},
|
|
29
|
+
{"name": "ride_cymbal_add", "category": "drums", "description": "Ride cymbal enters (jazz/DnB open feel)"},
|
|
30
|
+
# Layer entry / exit (9)
|
|
31
|
+
{"name": "layer_fade_in", "category": "layer", "description": "Track volume automation 0 → nominal over 4-8 bars"},
|
|
32
|
+
{"name": "layer_fade_out", "category": "layer", "description": "Track volume nominal → 0 over 8-16 bars"},
|
|
33
|
+
{"name": "layer_hard_cut_in", "category": "layer", "description": "Track unmutes on bar 1 of new section"},
|
|
34
|
+
{"name": "layer_hard_cut_out", "category": "layer", "description": "Track mutes on bar 1 of section change"},
|
|
35
|
+
{"name": "melody_first_entry", "category": "layer", "description": "Lead/melody track introduces for the first time"},
|
|
36
|
+
{"name": "texture_swell", "category": "layer", "description": "Atmosphere/pad rises from inaudible to audible over 32 bars"},
|
|
37
|
+
{"name": "sub_bass_entry", "category": "layer", "description": "Sub-bass track appears for first time"},
|
|
38
|
+
{"name": "pad_strip_to_silence", "category": "layer", "description": "Pad/harmonic layer removed for breakdown isolation"},
|
|
39
|
+
{"name": "vocal_chop_entry", "category": "layer", "description": "Vocal chop track enters"},
|
|
40
|
+
# Riser / Fall events (5)
|
|
41
|
+
{"name": "riser_swell", "category": "riser", "description": "Riser sample triggered at bar 13 of 16-bar phrase (arrives at bar 17)"},
|
|
42
|
+
{"name": "reverse_cymbal", "category": "riser", "description": "Reverse cymbal swell timed to arrive on bar 1 of next section"},
|
|
43
|
+
{"name": "noise_swell", "category": "riser", "description": "White noise volume −inf → 0 dB over 8 bars then cut on drop"},
|
|
44
|
+
{"name": "pitch_riser", "category": "riser", "description": "Sample or synth pitch automating upward over 4-8 bars"},
|
|
45
|
+
{"name": "downlifter", "category": "riser", "description": "Reverse riser / falling pitch for energy drop or breakdown arrival"},
|
|
46
|
+
# Sidechain / Dynamics (4)
|
|
47
|
+
{"name": "sidechain_activate", "category": "dynamics", "description": "Sidechain compressor (pad vs. kick) switches on at section start"},
|
|
48
|
+
{"name": "sidechain_deactivate", "category": "dynamics", "description": "Sidechain removed in breakdown, restoring pad sustain"},
|
|
49
|
+
{"name": "compressor_release_lengthen", "category": "dynamics", "description": "Compressor release extended for swelling effect"},
|
|
50
|
+
{"name": "drum_bus_saturation_increase", "category": "dynamics", "description": "Saturation drive increases during a build"},
|
|
51
|
+
# Harmonic / Melodic (5)
|
|
52
|
+
{"name": "chord_change", "category": "harmonic", "description": "Harmonic progression advances to next chord"},
|
|
53
|
+
{"name": "motif_restatement", "category": "harmonic", "description": "Primary hook returns after absence (ABAB form)"},
|
|
54
|
+
{"name": "motif_inversion", "category": "harmonic", "description": "Inverted motif (IDM/classical variation)"},
|
|
55
|
+
{"name": "motif_fragmentation", "category": "harmonic", "description": "Motif shortened to 1-2 notes, repeated (pre-climax)"},
|
|
56
|
+
{"name": "half_time_shift", "category": "harmonic", "description": "Note durations doubled (groove drops to half-time feel)"},
|
|
57
|
+
# Space / Reverb (3)
|
|
58
|
+
{"name": "reverb_send_increase", "category": "space", "description": "Send to reverb return increases over 4-8 bars"},
|
|
59
|
+
{"name": "dub_throw", "category": "space", "description": "Single 1-beat send spike to delay/reverb"},
|
|
60
|
+
{"name": "full_silence_bar", "category": "space", "description": "1-2 bars of total silence (highest-impact transition tool)"},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_event_lexicon(category: Optional[str] = None) -> list[dict]:
|
|
65
|
+
"""Return the lexicon, optionally filtered by category.
|
|
66
|
+
|
|
67
|
+
category: 'filter', 'drums', 'layer', 'riser', 'dynamics', 'harmonic', 'space', or None for all.
|
|
68
|
+
"""
|
|
69
|
+
if category is None:
|
|
70
|
+
return list(EVENT_LEXICON)
|
|
71
|
+
return [ev for ev in EVENT_LEXICON if ev["category"] == category]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Markdown parser for livepilot/skills/livepilot-core/references/genre-vocabularies.md.
|
|
2
|
+
|
|
3
|
+
Genre entries are demarcated by `## genre_name` headers. Each entry contains
|
|
4
|
+
descriptive content (kick character, bass register, harmonic palette, etc.).
|
|
5
|
+
We parse opportunistically — capture the raw markdown block per genre name,
|
|
6
|
+
let the agent reason on the content rather than over-structuring it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_GENRE_CACHE: Optional[dict[str, dict]] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _genre_md_path() -> Optional[Path]:
|
|
19
|
+
"""Locate genre-vocabularies.md by walking up from this file."""
|
|
20
|
+
here = Path(__file__).resolve()
|
|
21
|
+
for parent in here.parents:
|
|
22
|
+
candidate = parent / "livepilot" / "skills" / "livepilot-core" / "references" / "genre-vocabularies.md"
|
|
23
|
+
if candidate.exists():
|
|
24
|
+
return candidate
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load_genre_md() -> dict[str, dict]:
|
|
29
|
+
"""Parse the markdown into {genre_name_lowered: {raw: text, ...}}.
|
|
30
|
+
|
|
31
|
+
Cached after first call.
|
|
32
|
+
"""
|
|
33
|
+
global _GENRE_CACHE
|
|
34
|
+
if _GENRE_CACHE is not None:
|
|
35
|
+
return _GENRE_CACHE
|
|
36
|
+
path = _genre_md_path()
|
|
37
|
+
if path is None:
|
|
38
|
+
_GENRE_CACHE = {}
|
|
39
|
+
return _GENRE_CACHE
|
|
40
|
+
|
|
41
|
+
text = path.read_text(encoding="utf-8")
|
|
42
|
+
# Genres are demarcated by `## name` (or `### name`) — the structure
|
|
43
|
+
# may vary, so handle both. Use a regex split.
|
|
44
|
+
genres: dict[str, dict] = {}
|
|
45
|
+
sections = re.split(r"^##+\s+(.+?)\s*$", text, flags=re.MULTILINE)
|
|
46
|
+
# Re.split returns [pre, name1, body1, name2, body2, ...]
|
|
47
|
+
for i in range(1, len(sections), 2):
|
|
48
|
+
name = sections[i].strip()
|
|
49
|
+
if not name or name.lower() in ("vocabulary", "how to read this", "table of contents"):
|
|
50
|
+
continue
|
|
51
|
+
body = sections[i + 1] if i + 1 < len(sections) else ""
|
|
52
|
+
# Lowercase + underscore normalize the key
|
|
53
|
+
key = name.lower().replace(" ", "_").replace("-", "_")
|
|
54
|
+
genres[key] = {"name": name, "raw_md": body.strip()[:2000]} # cap to keep brief size sane
|
|
55
|
+
|
|
56
|
+
_GENRE_CACHE = genres
|
|
57
|
+
return _GENRE_CACHE
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_genre_context(genre: str) -> dict:
|
|
61
|
+
"""Load descriptive context for a parsed genre.
|
|
62
|
+
|
|
63
|
+
genre: parsed genre slug (techno, microhouse, ambient, etc.)
|
|
64
|
+
|
|
65
|
+
Returns a dict with content if found, or {"status": "no_match", ...} if not.
|
|
66
|
+
"""
|
|
67
|
+
if not genre:
|
|
68
|
+
return {"status": "no_genre_provided"}
|
|
69
|
+
genres = _load_genre_md()
|
|
70
|
+
key = genre.lower().replace(" ", "_").replace("-", "_")
|
|
71
|
+
if key in genres:
|
|
72
|
+
return genres[key]
|
|
73
|
+
# Try fuzzy: genre might be sub-genre matching a key contained in entry names
|
|
74
|
+
for k, v in genres.items():
|
|
75
|
+
if key in k or k in key:
|
|
76
|
+
return v
|
|
77
|
+
return {"status": "no_match", "parsed_genre": genre}
|