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.
Files changed (42) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +59 -13
  3. package/mcp_server/__init__.py +1 -1
  4. package/mcp_server/atlas/__init__.py +17 -3
  5. package/mcp_server/audit/__init__.py +6 -0
  6. package/mcp_server/audit/checks.py +618 -0
  7. package/mcp_server/audit/tools.py +232 -0
  8. package/mcp_server/composer/branch_producer.py +5 -2
  9. package/mcp_server/composer/develop/__init__.py +19 -0
  10. package/mcp_server/composer/develop/apply.py +217 -0
  11. package/mcp_server/composer/develop/brief_builder.py +269 -0
  12. package/mcp_server/composer/develop/seed_introspector.py +195 -0
  13. package/mcp_server/composer/engine.py +15 -521
  14. package/mcp_server/composer/fast/__init__.py +62 -0
  15. package/mcp_server/composer/fast/apply.py +533 -0
  16. package/mcp_server/composer/fast/brief_builder.py +1479 -0
  17. package/mcp_server/composer/fast/tier_classification.py +159 -0
  18. package/mcp_server/composer/framework/__init__.py +0 -0
  19. package/mcp_server/composer/framework/applier.py +179 -0
  20. package/mcp_server/composer/framework/artist_loader.py +63 -0
  21. package/mcp_server/composer/framework/brief.py +79 -0
  22. package/mcp_server/composer/framework/event_lexicon.py +71 -0
  23. package/mcp_server/composer/framework/genre_loader.py +77 -0
  24. package/mcp_server/composer/framework/intent_source.py +137 -0
  25. package/mcp_server/composer/framework/knowledge_pack.py +49 -0
  26. package/mcp_server/composer/framework/plan_compiler.py +10 -0
  27. package/mcp_server/composer/full/__init__.py +10 -0
  28. package/mcp_server/composer/full/apply.py +1139 -0
  29. package/mcp_server/composer/full/brief_builder.py +144 -0
  30. package/mcp_server/composer/full/engine.py +541 -0
  31. package/mcp_server/composer/full/layer_planner.py +491 -0
  32. package/mcp_server/composer/layer_planner.py +19 -465
  33. package/mcp_server/composer/sample_resolver.py +80 -7
  34. package/mcp_server/composer/tools.py +626 -28
  35. package/mcp_server/server.py +1 -0
  36. package/mcp_server/splice_client/client.py +7 -0
  37. package/mcp_server/tools/_analyzer_engine/sample.py +162 -6
  38. package/mcp_server/tools/_planner_engine.py +25 -63
  39. package/mcp_server/tools/analyzer.py +10 -4
  40. package/package.json +2 -2
  41. package/remote_script/LivePilot/__init__.py +1 -1
  42. 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}