livepilot 1.10.8 → 1.10.9

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 CHANGED
@@ -1,5 +1,133 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.10.9 — Second-pass audit + deferred-bugs shipped (April 18 2026)
4
+
5
+ Completes every non-feature item on the v1.10.8 audit backlog. 2116 → 2132
6
+ passing tests (+16 regression guards). 324 → 325 tools (`check_clip_key_consistency`
7
+ lands from BUG-D1). Every deferred BUG-C and BUG-D entry either ships or is
8
+ scoped to a follow-up feature; BUG-C4 is filed upstream as
9
+ [PrefectHQ/fastmcp#3967](https://github.com/PrefectHQ/fastmcp/issues/3967).
10
+
11
+ ### Ship-stoppers fixed
12
+
13
+ - **`send_capture` phantom 35s hang when receiver is None** —
14
+ [`mcp_server/m4l_bridge.py:441`](mcp_server/m4l_bridge.py). `send_command`
15
+ had a receiver-None guard; `send_capture` didn't. When UDP 9880 failed
16
+ to bind but the cache still reported connected, the OSC packet was
17
+ sent, the capture future was never registered, and the full 35s
18
+ timeout fired with a misleading "device may be busy" error. Added the
19
+ matching 5-line guard so the real cause surfaces immediately.
20
+ - **`utils.py` stale after Control Surface toggle** —
21
+ [`remote_script/LivePilot/__init__.py:43`](remote_script/LivePilot/__init__.py).
22
+ Every handler does `from .utils import get_track, get_device`.
23
+ `importlib.reload(devices)` rebinds those names, but because `utils`
24
+ was not in `_HANDLER_MODULES`, the re-import resolved against the
25
+ stale `sys.modules["LivePilot.utils"]`. Added `utils` first in the
26
+ reload order so edits to the shared helpers pick up on toggle too,
27
+ honoring the dev-workflow guarantee documented at the top of the file.
28
+
29
+ ### New tools
30
+
31
+ - **`check_clip_key_consistency`** (BUG-D1) — parses Splice-style
32
+ filename key tokens (`_D#min`, `_Ebmaj`, `_Cm`, …), cross-checks
33
+ against `get_detected_key`, returns the exact `set_clip_pitch(coarse=±N)`
34
+ call that would realign on mismatch. Handles `#`/`b` accidentals,
35
+ `min`/`m`/`maj` suffixes, absent tokens gracefully.
36
+ - **`get_session_diagnostics(check_clip_keys=True)`** — opt-in scan
37
+ across every audio clip, appending `clip_key_mismatch` warnings with
38
+ the one-call fix attached.
39
+
40
+ ### Features shipped
41
+
42
+ - **Session continuity persistence wired at startup** (BUG from external
43
+ audit). `bind_project_store_from_session()` now computes a project
44
+ fingerprint, opens the matching `ProjectStore`, AND rehydrates
45
+ `_threads` + `_turns` from disk. Wired into `server.py` lifespan with
46
+ a lazy rebind on first `record_turn_resolution` / `open_creative_thread`.
47
+ Creative threads and turn history now survive server restarts — the
48
+ README's "return to a project" claim is now load-bearing.
49
+ - **Taste-aware `propose_next_best_move`** — replaces keyword-only
50
+ matching with `0.55 × keyword + 0.30 × taste_alignment + 0.15 ×
51
+ (1 − avoidance) ± 0.10 × family_bonus`. Cold-start users identical to
52
+ before; users with history get personalized ranking via
53
+ `dimension_weights` and `dimension_avoidances`. New return fields:
54
+ `score_breakdown`, `taste_active`, `taste_evidence_count`.
55
+ - **Evaluation `taste_fit`** — previously hardcoded to `0.0`. Now
56
+ computed from `outcome_history` by matching same-direction deltas on
57
+ the same dimensions: ±0.2 per kept/undone match, neutral 0.5 baseline.
58
+ - **`AutomationGraph.coverage_pct`** (BUG-D2, detection half) — new
59
+ fields `coverage_pct`, `clip_envelope_count`, `clips_scanned`
60
+ distinguish "no automation exists" from "we couldn't probe" from
61
+ "sparse but present". Surfaced in `get_project_brain_summary` as
62
+ `automation_coverage_pct`.
63
+
64
+ ### Refactor (BUG-C1)
65
+
66
+ - **`mcp_server/tools/analyzer.py`** 1069 → 913 LOC. All 32
67
+ `@mcp.tool()` decorators stay in the module (FastMCP registration
68
+ order unchanged), helpers moved to `_analyzer_engine/`:
69
+ `context.py` (SpectralCache/bridge accessors, analyzer health check),
70
+ `sample.py` (Simpler post-load hygiene, filename heuristics),
71
+ `flucoma.py` (FluCoMa hint text, pitch-name table). Same
72
+ package-facade pattern as `_composition_engine` and `_agent_os_engine`.
73
+ - **`sync_metadata::get_domains()` now skips `_*`** directories + files
74
+ under `mcp_server/tools/`. Matches Python private-package convention,
75
+ prevents internal helpers from registering as false domains.
76
+
77
+ ### Resilience (BUG-C3)
78
+
79
+ - **`_get_all_tools()` probe chain extended** in `mcp_server/server.py`
80
+ to 4 paths: existing `_tool_manager._tools` and
81
+ `_local_provider._components`, plus speculative 3.3+ rename
82
+ `_local_provider._tools` and the future public `mcp.list_tools()`.
83
+ Each wrapped in try/except. All-empty fall-through now prints
84
+ `fastmcp.__version__` + the attempted probe labels — prior silent `[]`
85
+ return would disable schema coercion with no signal.
86
+ - **New `_assert_tool_registry_accessible()`** self-test runs at module
87
+ import. Empty registry or a count mismatch against
88
+ `tests/test_tools_contract.py` fails loudly via stderr.
89
+
90
+ ### Upstream (BUG-C4)
91
+
92
+ - **FastMCP feature request filed** —
93
+ [PrefectHQ/fastmcp#3967](https://github.com/PrefectHQ/fastmcp/issues/3967)
94
+ "Feature request: public tool-enumeration API". Migration is a
95
+ no-op once upstream lands a `mcp.list_tools()` or `mcp.tools`
96
+ surface — the probe chain already anticipates both.
97
+
98
+ ### Metadata + docs
99
+
100
+ - **`sync_metadata.py` extended** — catches both `N tools` and hyphenated
101
+ `N-tool`, now covers `manifest.json` + `docs/manual/intelligence.md` +
102
+ `.claude-plugin/marketplace.json`. New prose-claim checks for
103
+ bridge-command count, enriched-device YAML count, and `GENRE_DEFAULTS`
104
+ key count — every narrative number now traces to a code derivation.
105
+ - **README / CLAUDE.md / docs drift closed** — 28→30 bridge commands,
106
+ 81→71 enriched devices, 7→4 genre defaults, 323→325 tool count in
107
+ stale marketplace/plugin/manifest descriptors. Intelligence manual
108
+ example signatures for `record_positive_preference`,
109
+ `record_anti_preference`, `evaluate_move` updated to match the
110
+ shipping APIs.
111
+ - **Skill cleanups** — `livepilot-release` tool-count drift fixed;
112
+ `livepilot-wonder` reference paths corrected to point at their real
113
+ home in `livepilot-core`; arrangement vs composition-engine triggers
114
+ deduplicated (constructive vs analytical split).
115
+ - **New test** `tests/test_claim_consistency.py` — 12 guards running
116
+ `sync_metadata --check` from pytest, verifying `manifest.json` and
117
+ `intelligence.md` stay in the sweep, and asserting that
118
+ `bind_project_store_from_session` keeps a non-test caller.
119
+
120
+ ### Quality-of-life
121
+
122
+ - **`scripts/test.sh`** — blessed test entrypoint always uses `.venv/bin/python`,
123
+ closes the contributor trap where bare `pytest` failed 28 tests on
124
+ system Python.
125
+ - **`.gitignore`** additions: `m4l_device/*.pre-presentation-backup`,
126
+ `m4l_device/*.pre-*-backup`, `.mcp.json.disabled`.
127
+ - **Stale `livepilot-1.10.5.tgz`** removed from the repo root.
128
+
129
+ ---
130
+
3
131
  ## 1.10.8 — Deep audit fix pass (April 18 2026)
4
132
 
5
133
  Outcome of a cross-subsystem audit (Remote Script, MCP server, M4L bridge,
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  <p align="center">
19
19
  An agentic production system for Ableton Live 12.<br>
20
- 324 tools. 45 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
20
+ 325 tools. 45 domains. Device atlas. Splice integration. Auto-composition. Spectral perception. Technique memory.
21
21
  </p>
22
22
 
23
23
  <br>
@@ -38,7 +38,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
38
38
  | Layer | What it provides |
39
39
  |-------|-----------------|
40
40
  | **Deterministic Tools** | Direct control: transport, tracks, clips, notes, devices, scenes, mixing, arrangement, browser, automation |
41
- | **Device Atlas** | Knowledge of every device in Ableton's library — 1305 devices indexed by name, URI, category, tag, and genre. 81 enriched with sonic intelligence. 683 drum kits mapped |
41
+ | **Device Atlas** | Knowledge of every device in Ableton's library — 1305 devices indexed by name, URI, category, tag, and genre. 71 enriched with sonic intelligence. 683 drum kits mapped |
42
42
  | **Sample Engine** | Three-source sample intelligence — searches Ableton's browser, your filesystem, and Splice's catalog simultaneously. 6 fitness critics score every result. 29 processing techniques |
43
43
  | **Spectral Perception** | Real-time ears via M4L — 8-band FFT, RMS/peak metering, Krumhansl-Schmuckler key detection, pitch tracking. Closes the feedback loop so the AI hears its own changes |
44
44
  | **Technique Memory** | Persistent library of production decisions. Save a beat pattern, device chain, or mix template. Recall by mood, genre, or texture across sessions |
@@ -58,7 +58,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
58
58
  │ │
59
59
  │ Device Atlas 8-band FFT recall by mood, │
60
60
  │ 1305 devices RMS / peak genre, texture │
61
- 81 enriched pitch tracking 29 techniques │
61
+ 71 enriched pitch tracking 29 techniques │
62
62
  │ 683 drum kits key detection replay into session │
63
63
  │ │
64
64
  │ Sample Engine Corpus Intelligence Taste Graph │
@@ -79,7 +79,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
79
79
  │ └─────────────────┼──────────────────┘ │
80
80
  │ ▼ │
81
81
  │ ┌─────────────────┐ │
82
- │ │ 324 MCP Tools │ │
82
+ │ │ 325 MCP Tools │ │
83
83
  │ │ 45 domains │ │
84
84
  │ └────────┬────────┘ │
85
85
  │ │ │
@@ -100,15 +100,15 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
100
100
 
101
101
  **MCP Server** (`mcp_server/`) — Python FastMCP server. Validates inputs, routes commands to the Remote Script over TCP, manages the M4L bridge, runs the atlas, sample engine, composer, and all intelligence engines. This is what your AI client connects to.
102
102
 
103
- **M4L Bridge** (`m4l_device/`) — Optional Max for Live Audio Effect on the master track. Provides deep LOM access through Max's LiveAPI that the ControlSurface API can't reach. UDP 9880 (M4L to server) carries spectral data and LiveAPI responses. OSC 9881 (server to M4L) sends commands. 36 bridge tools (backed by 29 bridge commands) for hidden parameters, Simpler internals, warp markers, display values, and Simpler warp / Compressor sidechain writes that live on child objects Python can't reach.
103
+ **M4L Bridge** (`m4l_device/`) — Optional Max for Live Audio Effect on the master track. Provides deep LOM access through Max's LiveAPI that the ControlSurface API can't reach. UDP 9880 (M4L to server) carries spectral data and LiveAPI responses. OSC 9881 (server to M4L) sends commands. The 32 spectral/analyzer tools strictly require the bridge; device and sample tools that call the bridge also have graceful fallbacks, so core functionality works without it. Backed by 30 bridge commands for hidden parameters, Simpler internals, warp markers, display values, and Simpler warp / Compressor sidechain writes that live on child objects Python can't reach.
104
104
 
105
- **Device Atlas** (`mcp_server/atlas/`) — In-memory indexed JSON database. 1305 devices with browser URIs, 81 enriched with YAML sonic intelligence profiles (mood, genre, texture, recommended chains). 6 indexes: by_id, by_name, by_uri, by_category, by_tag, by_genre. The AI never hallucinates a device name or preset — it always resolves against the atlas first.
105
+ **Device Atlas** (`mcp_server/atlas/`) — In-memory indexed JSON database. 1305 devices with browser URIs, 71 enriched with YAML sonic intelligence profiles (mood, genre, texture, recommended chains). 6 indexes: by_id, by_name, by_uri, by_category, by_tag, by_genre. The AI never hallucinates a device name or preset — it always resolves against the atlas first.
106
106
 
107
107
  **Sample Engine** (`mcp_server/sample_engine/`) — Searches three sources simultaneously: BrowserSource (Ableton's library), SpliceSource (local Splice catalog via SQLite), FilesystemSource (user directories). Every result passes through a 6-critic fitness battery (key, tempo, spectral, genre, mood, technical). 29 processing techniques (Surgeon precision vs. Alchemist experimentation). Builds complete sample processing plans with warp, slice, and effect recommendations.
108
108
 
109
109
  **Splice Client** (`mcp_server/splice_client/`) — Searches Splice's catalog through two layers: the local SQLite database (`sounds.db`, already-downloaded samples) and the live gRPC API (full catalog, including samples you haven't downloaded yet). The gRPC client auto-detects Splice's dynamic port via `port.conf`, handles self-signed TLS, and enforces a 5-credit safety floor before any download. Per-call timeouts (5–10s) prevent a hung Splice process from stalling the MCP event loop. Graceful fallback to SQL-only if grpcio isn't installed. No API key needed — authentication comes from the running Splice desktop app.
110
110
 
111
- **Composer** (`mcp_server/composer/`) — Prompt-to-plan pipeline. Parses natural language ("dark minimal techno 128bpm with industrial textures") into a CompositionIntent (genre, mood, tempo, key). Plans layers using role templates (kick, bass, percussion, texture, lead, pad, fx). Compiles to a step-by-step plan of tool calls that the agent executes. Does not execute autonomously — returns the plan. 7 genre defaults (techno, house, ambient, hip-hop, dnb, dub, experimental).
111
+ **Composer** (`mcp_server/composer/`) — Prompt-to-plan pipeline. Parses natural language ("dark minimal techno 128bpm with industrial textures") into a CompositionIntent (genre, mood, tempo, key). Plans layers using role templates (kick, bass, percussion, texture, lead, pad, fx). Compiles to a step-by-step plan of tool calls that the agent executes. Does not execute autonomously — returns the plan. 4 genre defaults (house, techno, trap, ambient) genres outside this set fall back to a neutral layer plan.
112
112
 
113
113
  **Corpus** (`mcp_server/corpus/`) — Parsed device-knowledge markdown converted to queryable Python structures: EmotionalRecipe, GenreChain, PhysicalModelRecipe, AutomationGesture. Feeds Wonder Mode, Sound Design critics, and the Composer with deep creative knowledge at runtime — not just LLM prompts, actual structured data.
114
114
 
@@ -120,7 +120,7 @@ Most MCP servers are tool collections — they execute commands. LivePilot is an
120
120
 
121
121
  ## The Intelligence Layer
122
122
 
123
- 12 engines sit on top of the 324 tools. They give the AI musical judgment, not just musical execution.
123
+ 12 engines sit on top of the 325 tools. They give the AI musical judgment, not just musical execution.
124
124
 
125
125
  ### SongBrain — What the Song Is
126
126
 
@@ -156,7 +156,7 @@ When a session is stuck — repeated undos, overpolished loops, no structural pr
156
156
 
157
157
  ### Hook Hunter
158
158
 
159
- Identifies the most salient musical idea — ranks by rhythmic distinctiveness, melodic contour, repetition. Tracks whether hooks are developed, neglected, or undermined. Flags when a transition fails to deliver expected payoff.
159
+ Identifies the most salient musical idea — ranks candidates by recurrence across scenes, motif salience, and section placement (payoff-section boost). Tracks whether hooks are developed, neglected, or undermined, and flags when a transition fails to deliver expected payoff. Rhythm-side ranking is currently heuristic (drum-track detection + clip reuse); true onset-based rhythmic features are on the roadmap.
160
160
 
161
161
  ### Session Continuity
162
162
 
@@ -172,7 +172,7 @@ Every engine follows: **measure before → act → measure after → compare**.
172
172
 
173
173
  ## Tools
174
174
 
175
- 324 tools across 45 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
175
+ 325 tools across 45 domains. Highlights below — [full catalog here](docs/manual/tool-catalog.md).
176
176
 
177
177
  <br>
178
178
 
@@ -204,7 +204,7 @@ Every engine follows: **measure before → act → measure after → compare**.
204
204
  The M4L Analyzer sits on the master track. UDP 9880 carries spectral data to the server. OSC 9881 sends commands back.
205
205
 
206
206
  > [!TIP]
207
- > All 281 core tools work without the analyzer — it adds 36 bridge tools and closes the feedback loop.
207
+ > Most tools work without the analyzer — it adds 32 spectral/analyzer tools (frequency, loudness, perception) and closes the feedback loop.
208
208
 
209
209
  ```
210
210
  SPECTRAL ─────── 8-band frequency decomposition (sub → air)
@@ -232,7 +232,7 @@ The atlas is an in-memory indexed database of Ableton's entire device library.
232
232
 
233
233
  ```
234
234
  1305 devices total
235
- 81 enriched with sonic intelligence (mood, genre, texture, chains)
235
+ 71 enriched with sonic intelligence (mood, genre, texture, chains)
236
236
  683 drum kits mapped with note assignments
237
237
  6 indexes: by_id, by_name, by_uri, by_category, by_tag, by_genre
238
238
  ```
@@ -317,7 +317,7 @@ Prompt-to-plan auto-composition engine.
317
317
  └─────────────────┘
318
318
  ```
319
319
 
320
- - 7 genre defaults: techno, house, ambient, hip-hop, dnb, dub, experimental
320
+ - 4 genre defaults: house, techno, trap, ambient (unknown genres fall back to a neutral plan)
321
321
  - Returns step-by-step plans — the agent executes each tool call in sequence
322
322
  - `compose` — plan a multi-layer composition from text prompt
323
323
  - `augment_with_samples` — plan sample-based layers for existing session
@@ -360,7 +360,7 @@ The V2 intelligence layer. These tools analyze, diagnose, plan, evaluate, and le
360
360
  | Creative Constraints | 5 | constraint activation, reference-inspired variants |
361
361
  | Preview Studio | 5 | variant creation, preview rendering, comparison, commit |
362
362
 
363
- > **[View all 324 tools →](docs/manual/tool-catalog.md)**
363
+ > **[View all 325 tools →](docs/manual/tool-catalog.md)**
364
364
 
365
365
  <br>
366
366
 
@@ -587,7 +587,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture details, code guidelines
587
587
 
588
588
  | Document | What's inside |
589
589
  |----------|---------------|
590
- | [Manual](docs/manual/index.md) | Complete reference: architecture, all 324 tools, workflows |
590
+ | [Manual](docs/manual/index.md) | Complete reference: architecture, all 325 tools, workflows |
591
591
  | [Intelligence Layer](docs/manual/intelligence.md) | How the 12 engines connect — conductor, moves, preview, evaluation |
592
592
  | [Device Atlas](docs/manual/device-atlas.md) | 1305 devices indexed — search, suggest, chain building |
593
593
  | [Samples & Slicing](docs/manual/samples.md) | 3-source search, fitness critics, slice workflows |
Binary file
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.10.8"});
98
+ send_response({"ok": true, "version": "1.10.9"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.10.8"
2
+ __version__ = "1.10.9"
@@ -31,6 +31,66 @@ from .policy import apply_hard_rules
31
31
  # ── Sonic Evaluator ──────────────────────────────────────────────────
32
32
 
33
33
 
34
+ def _compute_taste_fit(
35
+ dimension_changes: dict[str, dict],
36
+ outcome_history: Optional[list[dict]],
37
+ ) -> float:
38
+ """Score how well this move aligns with the user's recent taste.
39
+
40
+ Shipped in v1.10.9 — previously hardcoded to 0.0.
41
+
42
+ For each dimension that moved (in ``dimension_changes``), look at the
43
+ user's last few kept/undone outcomes for the same dimension:
44
+ * kept with the same direction of delta → +0.2 per match
45
+ * undone with the same direction of delta → −0.2 per match
46
+
47
+ Returns a value in 0..1 (0.5 = neutral, neither signal). Empty history
48
+ returns 0.5, which is the correct "no information yet" neutral the
49
+ composite score already expects.
50
+
51
+ ``outcome_history`` entries are dicts of the shape::
52
+
53
+ {"dimension": "punch", "delta": 0.12, "kept": True}
54
+
55
+ Callers that pass a richer shape should extract those three fields.
56
+ Malformed entries are skipped silently so a schema bump upstream can't
57
+ break the evaluator.
58
+ """
59
+ if not outcome_history or not dimension_changes:
60
+ return 0.5
61
+
62
+ # Only weigh the most recent slice — taste drifts, and older signals
63
+ # shouldn't veto a current evaluation.
64
+ recent = outcome_history[-10:]
65
+
66
+ adjustment = 0.0
67
+ matched = 0
68
+ for dim, change in dimension_changes.items():
69
+ current_delta = change.get("delta", 0.0)
70
+ current_sign = 1 if current_delta > 0 else (-1 if current_delta < 0 else 0)
71
+ if current_sign == 0:
72
+ continue
73
+ for entry in recent:
74
+ if not isinstance(entry, dict):
75
+ continue
76
+ if entry.get("dimension") != dim:
77
+ continue
78
+ past_delta = entry.get("delta")
79
+ if not isinstance(past_delta, (int, float)):
80
+ continue
81
+ past_sign = 1 if past_delta > 0 else (-1 if past_delta < 0 else 0)
82
+ if past_sign != current_sign:
83
+ continue
84
+ kept = bool(entry.get("kept", False))
85
+ adjustment += 0.2 if kept else -0.2
86
+ matched += 1
87
+
88
+ if matched == 0:
89
+ return 0.5
90
+ # Neutral baseline 0.5 + averaged adjustment, clamped to [0, 1].
91
+ return _clamp(0.5 + adjustment / matched)
92
+
93
+
34
94
  def evaluate_sonic_move(
35
95
  request: EvaluationRequest,
36
96
  outcome_history: Optional[list[dict]] = None,
@@ -109,12 +169,13 @@ def evaluate_sonic_move(
109
169
  measurable_component = _clamp(0.5 + measurable_delta)
110
170
  preservation = _clamp(1.0 - collateral_damage * 5)
111
171
  confidence = measurable_count / max(len(targets), 1)
172
+ taste_fit = _compute_taste_fit(dimension_changes, outcome_history)
112
173
 
113
174
  score = (
114
175
  0.30 * goal_fit
115
176
  + 0.25 * measurable_component
116
177
  + 0.15 * preservation
117
- + 0.10 * 0.0 # taste_fit: placeholder, no history in fabric v1
178
+ + 0.10 * taste_fit
118
179
  + 0.10 * confidence
119
180
  + 0.10 * 1.0 # reversibility: 1.0 for undo-able moves
120
181
  )
@@ -443,17 +443,28 @@ class M4LBridge:
443
443
  if not self.cache.is_connected:
444
444
  return {"error": "LivePilot Analyzer not connected. Drop it on the master track."}
445
445
 
446
+ # Fail fast if there is no receiver to correlate the reply. Prior
447
+ # versions sent the OSC packet anyway, never registered a future,
448
+ # and then waited out the full 35s timeout with a misleading
449
+ # "device may be busy or removed" diagnosis — the real cause was
450
+ # "no receiver wired" (UDP 9880 failed to bind at startup).
451
+ if self.receiver is None:
452
+ return {
453
+ "error": "M4L bridge has no active receiver — the UDP 9880 "
454
+ "listener did not start. Check server startup logs "
455
+ "for a bind failure on port 9880."
456
+ }
457
+
446
458
  if self._cmd_lock is None:
447
459
  self._cmd_lock = asyncio.Lock()
448
460
  async with self._cmd_lock:
449
461
  # Cancel any stale capture future before creating a new one
450
- if self.receiver and self.receiver._capture_future and not self.receiver._capture_future.done():
462
+ if self.receiver._capture_future and not self.receiver._capture_future.done():
451
463
  self.receiver._capture_future.cancel()
452
464
 
453
465
  loop = asyncio.get_running_loop()
454
466
  future = loop.create_future()
455
- if self.receiver:
456
- self.receiver.set_capture_future(future)
467
+ self.receiver.set_capture_future(future)
457
468
 
458
469
  osc_data = self._build_osc(command, args)
459
470
  self._sock.sendto(osc_data, self._m4l_addr)
@@ -463,8 +474,7 @@ class M4LBridge:
463
474
  return result
464
475
  except asyncio.TimeoutError:
465
476
  # Clean up the dangling future
466
- if self.receiver:
467
- self.receiver._capture_future = None
477
+ self.receiver._capture_future = None
468
478
  return {"error": "M4L capture timeout — device may be busy or removed"}
469
479
 
470
480
  async def cancel_capture_future(self) -> None:
@@ -12,6 +12,7 @@ def build_automation_graph(
12
12
  track_infos: list[dict],
13
13
  sections: list[dict] | None = None,
14
14
  clip_automation: list[dict] | None = None,
15
+ clips_scanned: int = 0,
15
16
  ) -> AutomationGraph:
16
17
  """Build an AutomationGraph covering both device-parameter automation
17
18
  hints and real clip envelopes (BUG-E2).
@@ -27,11 +28,17 @@ def build_automation_graph(
27
28
  parameter_name, parameter_type, device_name}].
28
29
  This is the ground truth — `device.parameters[i].is_automated`
29
30
  only reflects mapping state, not the presence of an envelope.
31
+ clips_scanned: total number of session clips the caller actually
32
+ probed for envelopes. Used to compute ``coverage_pct``; pass 0
33
+ when the caller couldn't enumerate clips (unknown → 0.0).
30
34
 
31
35
  Returns:
32
- AutomationGraph with automated_params and density_by_section.
36
+ AutomationGraph with automated_params, density_by_section, and
37
+ the v1.10.9 coverage signals (coverage_pct, clip_envelope_count,
38
+ clips_scanned).
33
39
  """
34
40
  graph = AutomationGraph()
41
+ graph.clips_scanned = max(0, int(clips_scanned))
35
42
 
36
43
  if not track_infos and not clip_automation:
37
44
  return graph
@@ -121,4 +128,19 @@ def build_automation_graph(
121
128
  else:
122
129
  graph.density_by_section[section_id] = 0.0
123
130
 
131
+ # BUG-D2 coverage signals.
132
+ # clip_envelope_count = distinct (track, clip) slots containing any envelope.
133
+ clip_slots_with_envelope: set[tuple[int, int | None]] = set()
134
+ for env in clip_automation or []:
135
+ clip_slots_with_envelope.add(
136
+ (int(env.get("track_index", -1)), env.get("clip_index"))
137
+ )
138
+ graph.clip_envelope_count = len(clip_slots_with_envelope)
139
+ if graph.clips_scanned > 0:
140
+ graph.coverage_pct = min(
141
+ 1.0, graph.clip_envelope_count / float(graph.clips_scanned)
142
+ )
143
+ else:
144
+ graph.coverage_pct = 0.0
145
+
124
146
  return graph
@@ -24,6 +24,7 @@ def build_project_state_from_data(
24
24
  notes_map: Optional[dict[str, dict[int, list[dict]]]] = None,
25
25
  arrangement_clips: Optional[dict] = None,
26
26
  clip_automation: Optional[list[dict]] = None,
27
+ clips_scanned: int = 0,
27
28
  analyzer_ok: bool = False,
28
29
  flucoma_ok: bool = False,
29
30
  plugin_health: Optional[dict[str, Any]] = None,
@@ -107,6 +108,7 @@ def build_project_state_from_data(
107
108
  track_infos=track_infos or [],
108
109
  sections=section_dicts_for_auto,
109
110
  clip_automation=clip_automation or [],
111
+ clips_scanned=clips_scanned,
110
112
  )
111
113
  state.automation_graph.freshness.mark_fresh(state.revision)
112
114
 
@@ -205,16 +205,35 @@ class RoleGraph:
205
205
 
206
206
  @dataclass
207
207
  class AutomationGraph:
208
- """Automation presence and gesture density."""
208
+ """Automation presence and gesture density.
209
+
210
+ ``coverage_pct`` is the fraction of scanned clips that have at least
211
+ one automation envelope (0.0–1.0). Introduced in v1.10.9 to close
212
+ BUG-D2's "is this session missing automation?" signal — downstream
213
+ engines (Wonder Mode, Sound Design, etc.) can branch on a low
214
+ coverage value to recommend filter sweeps, volume crescendos, and
215
+ dub-style handoffs that the producer hasn't written yet.
216
+
217
+ ``clip_envelope_count`` is the raw total of per-clip envelopes
218
+ discovered; distinguishes "no automation in the project at all"
219
+ (count=0) from "automation exists but is lightly used" (count>0 but
220
+ coverage_pct<0.2).
221
+ """
209
222
 
210
223
  automated_params: list[dict] = field(default_factory=list)
211
224
  density_by_section: dict[str, float] = field(default_factory=dict)
225
+ coverage_pct: float = 0.0
226
+ clip_envelope_count: int = 0
227
+ clips_scanned: int = 0
212
228
  freshness: FreshnessInfo = field(default_factory=FreshnessInfo)
213
229
 
214
230
  def to_dict(self) -> dict:
215
231
  return {
216
232
  "automated_params": list(self.automated_params),
217
233
  "density_by_section": dict(self.density_by_section),
234
+ "coverage_pct": round(self.coverage_pct, 3),
235
+ "clip_envelope_count": self.clip_envelope_count,
236
+ "clips_scanned": self.clips_scanned,
218
237
  "freshness": self.freshness.to_dict(),
219
238
  }
220
239
 
@@ -131,11 +131,15 @@ def build_project_brain(ctx: Context) -> dict:
131
131
  # automation actually lives on each clip (session + arrangement). We
132
132
  # walk every clip slot that has a clip and ask get_clip_automation, then
133
133
  # aggregate into a flat list keyed by section.
134
+ #
135
+ # clips_scanned is the denominator for coverage_pct (BUG-D2) — it
136
+ # counts how many (track, scene) slots we probed, regardless of
137
+ # whether an envelope came back. Without this, a session with zero
138
+ # automation would be indistinguishable from a session where we
139
+ # failed to probe, which is exactly the ambiguity BUG-D2 flagged.
134
140
  clip_automation: list[dict] = []
141
+ clips_scanned = 0
135
142
  try:
136
- # Iterate session scenes x tracks, plus arrangement clips we already have.
137
- # Use the raw enumerate index for section_id so it stays aligned with
138
- # arrangement_graph sections (which use the same scheme — see E1 fix).
139
143
  for scene_idx, scene in enumerate(scenes or []):
140
144
  scene_name = str(scene.get("name", "")).strip()
141
145
  if not scene_name:
@@ -143,6 +147,7 @@ def build_project_brain(ctx: Context) -> dict:
143
147
  section_id = f"sec_{scene_idx:02d}"
144
148
  for track in tracks:
145
149
  t_idx = track.get("index", 0)
150
+ clips_scanned += 1
146
151
  try:
147
152
  auto_resp = ableton.send_command("get_clip_automation", {
148
153
  "track_index": t_idx,
@@ -196,6 +201,7 @@ def build_project_brain(ctx: Context) -> dict:
196
201
  notes_map=notes_map if notes_map else None,
197
202
  arrangement_clips=arrangement_clips if arrangement_clips else None,
198
203
  clip_automation=clip_automation if clip_automation else None,
204
+ clips_scanned=clips_scanned,
199
205
  analyzer_ok=analyzer_ok,
200
206
  flucoma_ok=flucoma_ok,
201
207
  session_ok=True,
@@ -230,6 +236,7 @@ def get_project_brain_summary(ctx: Context) -> dict:
230
236
  "section_count": len(state.arrangement_graph.sections),
231
237
  "role_count": len(state.role_graph.roles),
232
238
  "automated_param_count": len(state.automation_graph.automated_params),
239
+ "automation_coverage_pct": round(state.automation_graph.coverage_pct, 3),
233
240
  "tempo": state.session_graph.tempo,
234
241
  "time_signature": state.session_graph.time_signature,
235
242
  "is_stale": state.is_stale(),