livepilot 1.9.23 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +119 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +144 -13
- package/bin/livepilot.js +87 -0
- package/installer/codex.js +147 -0
- package/livepilot/.Codex-plugin/plugin.json +2 -2
- package/livepilot/.claude-plugin/plugin.json +2 -2
- package/livepilot/skills/livepilot-core/SKILL.md +21 -4
- package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
- package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
- package/livepilot/skills/livepilot-core/references/overview.md +13 -9
- package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
- package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
- package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
- package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
- package/livepilot/skills/livepilot-release/SKILL.md +19 -5
- package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
- package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
- package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
- package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
- package/livepilot.mcpb +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/manifest.json +2 -2
- package/mcp_server/__init__.py +1 -1
- package/mcp_server/atlas/__init__.py +357 -0
- package/mcp_server/atlas/device_atlas.json +44067 -0
- package/mcp_server/atlas/enrichments/__init__.py +111 -0
- package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
- package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
- package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
- package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
- package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
- package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
- package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
- package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
- package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
- package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
- package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
- package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
- package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
- package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
- package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
- package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
- package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
- package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
- package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
- package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
- package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
- package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
- package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
- package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
- package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
- package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
- package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
- package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
- package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
- package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
- package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
- package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
- package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
- package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
- package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
- package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
- package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
- package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
- package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
- package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
- package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
- package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
- package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
- package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
- package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
- package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
- package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
- package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
- package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
- package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
- package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
- package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
- package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
- package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
- package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
- package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
- package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
- package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
- package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
- package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
- package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
- package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
- package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
- package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
- package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
- package/mcp_server/atlas/scanner.py +236 -0
- package/mcp_server/atlas/tools.py +224 -0
- package/mcp_server/composer/__init__.py +1 -0
- package/mcp_server/composer/engine.py +452 -0
- package/mcp_server/composer/layer_planner.py +427 -0
- package/mcp_server/composer/prompt_parser.py +329 -0
- package/mcp_server/composer/tools.py +201 -0
- package/mcp_server/connection.py +53 -8
- package/mcp_server/corpus/__init__.py +377 -0
- package/mcp_server/device_forge/__init__.py +1 -0
- package/mcp_server/device_forge/builder.py +377 -0
- package/mcp_server/device_forge/models.py +142 -0
- package/mcp_server/device_forge/templates.py +483 -0
- package/mcp_server/device_forge/tools.py +162 -0
- package/mcp_server/hook_hunter/analyzer.py +23 -0
- package/mcp_server/hook_hunter/models.py +1 -0
- package/mcp_server/hook_hunter/tools.py +4 -2
- package/mcp_server/m4l_bridge.py +1 -0
- package/mcp_server/memory/taste_graph.py +68 -1
- package/mcp_server/memory/tools.py +15 -4
- package/mcp_server/musical_intelligence/detectors.py +14 -1
- package/mcp_server/musical_intelligence/tools.py +11 -8
- package/mcp_server/persistence/__init__.py +1 -0
- package/mcp_server/persistence/base_store.py +82 -0
- package/mcp_server/persistence/project_store.py +106 -0
- package/mcp_server/persistence/taste_store.py +122 -0
- package/mcp_server/preview_studio/models.py +1 -0
- package/mcp_server/preview_studio/tools.py +56 -13
- package/mcp_server/runtime/capability.py +66 -0
- package/mcp_server/runtime/capability_probe.py +137 -0
- package/mcp_server/runtime/execution_router.py +143 -0
- package/mcp_server/runtime/live_version.py +102 -0
- package/mcp_server/runtime/remote_commands.py +87 -0
- package/mcp_server/runtime/tools.py +18 -4
- package/mcp_server/sample_engine/__init__.py +1 -0
- package/mcp_server/sample_engine/analyzer.py +216 -0
- package/mcp_server/sample_engine/critics.py +390 -0
- package/mcp_server/sample_engine/models.py +193 -0
- package/mcp_server/sample_engine/moves.py +127 -0
- package/mcp_server/sample_engine/planner.py +186 -0
- package/mcp_server/sample_engine/sources.py +540 -0
- package/mcp_server/sample_engine/techniques.py +908 -0
- package/mcp_server/sample_engine/tools.py +442 -0
- package/mcp_server/semantic_moves/__init__.py +3 -0
- package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
- package/mcp_server/semantic_moves/mix_moves.py +41 -41
- package/mcp_server/semantic_moves/performance_moves.py +13 -13
- package/mcp_server/semantic_moves/sample_compilers.py +372 -0
- package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
- package/mcp_server/semantic_moves/tools.py +18 -17
- package/mcp_server/semantic_moves/transition_moves.py +16 -16
- package/mcp_server/server.py +51 -0
- package/mcp_server/services/__init__.py +1 -0
- package/mcp_server/services/motif_service.py +67 -0
- package/mcp_server/session_continuity/tracker.py +29 -1
- package/mcp_server/song_brain/builder.py +28 -1
- package/mcp_server/song_brain/models.py +4 -0
- package/mcp_server/song_brain/tools.py +20 -2
- package/mcp_server/sound_design/critics.py +89 -1
- package/mcp_server/splice_client/__init__.py +1 -0
- package/mcp_server/splice_client/client.py +347 -0
- package/mcp_server/splice_client/models.py +96 -0
- package/mcp_server/splice_client/protos/__init__.py +1 -0
- package/mcp_server/splice_client/protos/app_pb2.py +319 -0
- package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
- package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
- package/mcp_server/tools/arrangement.py +69 -0
- package/mcp_server/tools/automation.py +15 -2
- package/mcp_server/tools/devices.py +117 -6
- package/mcp_server/tools/notes.py +37 -4
- package/mcp_server/wonder_mode/diagnosis.py +5 -0
- package/mcp_server/wonder_mode/engine.py +85 -1
- package/mcp_server/wonder_mode/tools.py +6 -1
- package/package.json +12 -2
- package/remote_script/LivePilot/__init__.py +8 -1
- package/remote_script/LivePilot/arrangement.py +114 -0
- package/remote_script/LivePilot/browser.py +56 -1
- package/remote_script/LivePilot/devices.py +236 -6
- package/remote_script/LivePilot/mixing.py +8 -3
- package/remote_script/LivePilot/server.py +5 -1
- package/remote_script/LivePilot/transport.py +3 -0
- package/remote_script/LivePilot/version_detect.py +78 -0
- package/scripts/sync_metadata.py +132 -0
package/mcp_server/m4l_bridge.py
CHANGED
|
@@ -99,6 +99,9 @@ class TasteGraph:
|
|
|
99
99
|
|
|
100
100
|
# ── Update methods ───────────────────────────────────────────────
|
|
101
101
|
|
|
102
|
+
# Persistent store reference (set by build_taste_graph when available)
|
|
103
|
+
_persistent_store: object = None
|
|
104
|
+
|
|
102
105
|
def record_move_outcome(
|
|
103
106
|
self, move_id: str, family: str, kept: bool, score: float = 0.0
|
|
104
107
|
) -> None:
|
|
@@ -120,6 +123,13 @@ class TasteGraph:
|
|
|
120
123
|
self.evidence_count += 1
|
|
121
124
|
self.last_updated_ms = now
|
|
122
125
|
|
|
126
|
+
# Write-back to persistent store
|
|
127
|
+
if self._persistent_store is not None:
|
|
128
|
+
try:
|
|
129
|
+
self._persistent_store.record_move_outcome(move_id, family, kept, score)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass # persistence is best-effort
|
|
132
|
+
|
|
123
133
|
def record_device_use(self, device_name: str, positive: bool = True) -> None:
|
|
124
134
|
"""Update device affinity from usage."""
|
|
125
135
|
now = int(time.time() * 1000)
|
|
@@ -245,10 +255,17 @@ class TasteGraph:
|
|
|
245
255
|
def build_taste_graph(
|
|
246
256
|
taste_store=None, # TasteMemoryStore
|
|
247
257
|
anti_store=None, # AntiMemoryStore
|
|
258
|
+
persistent_store=None, # PersistentTasteStore (optional)
|
|
248
259
|
) -> TasteGraph:
|
|
249
|
-
"""Build a TasteGraph from existing memory stores.
|
|
260
|
+
"""Build a TasteGraph from existing memory stores.
|
|
261
|
+
|
|
262
|
+
When persistent_store is provided, hydrates move_family_scores,
|
|
263
|
+
device_affinities, and novelty_band from disk — these survive
|
|
264
|
+
server restart.
|
|
265
|
+
"""
|
|
250
266
|
graph = TasteGraph()
|
|
251
267
|
|
|
268
|
+
# Session-scoped dimensions (in-memory)
|
|
252
269
|
if taste_store:
|
|
253
270
|
for dim in taste_store.get_taste_dimensions():
|
|
254
271
|
if dim.evidence_count > 0:
|
|
@@ -258,4 +275,54 @@ def build_taste_graph(
|
|
|
258
275
|
for pref in anti_store.get_anti_preferences():
|
|
259
276
|
graph.dimension_avoidances[pref.dimension] = pref.direction
|
|
260
277
|
|
|
278
|
+
# Persistent state (from disk)
|
|
279
|
+
if persistent_store is not None:
|
|
280
|
+
persisted = persistent_store.get_all()
|
|
281
|
+
|
|
282
|
+
# Move family scores
|
|
283
|
+
for move_id, outcome in persisted.get("move_outcomes", {}).items():
|
|
284
|
+
family = outcome.get("family", "")
|
|
285
|
+
if family and family not in graph.move_family_scores:
|
|
286
|
+
from .taste_graph import MoveFamilyScore
|
|
287
|
+
graph.move_family_scores[family] = MoveFamilyScore(family=family)
|
|
288
|
+
if family:
|
|
289
|
+
fam = graph.move_family_scores[family]
|
|
290
|
+
fam.kept_count += outcome.get("kept_count", 0)
|
|
291
|
+
fam.undone_count += outcome.get("undone_count", 0)
|
|
292
|
+
total = fam.kept_count + fam.undone_count
|
|
293
|
+
if total > 0:
|
|
294
|
+
fam.score = round((fam.kept_count - fam.undone_count) / total, 3)
|
|
295
|
+
|
|
296
|
+
# Novelty band
|
|
297
|
+
graph.novelty_band = persisted.get("novelty_band", 0.5)
|
|
298
|
+
|
|
299
|
+
# Device affinities
|
|
300
|
+
for dev_name, dev_data in persisted.get("device_affinities", {}).items():
|
|
301
|
+
from .taste_graph import DeviceAffinity
|
|
302
|
+
graph.device_affinities[dev_name] = DeviceAffinity(
|
|
303
|
+
device_name=dev_name,
|
|
304
|
+
affinity=dev_data.get("affinity", 0.0),
|
|
305
|
+
use_count=dev_data.get("use_count", 0),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Evidence count
|
|
309
|
+
graph.evidence_count = max(
|
|
310
|
+
graph.evidence_count, persisted.get("evidence_count", 0)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Dimension weights from persistent store (merged, session takes precedence)
|
|
314
|
+
for dim, val in persisted.get("dimension_weights", {}).items():
|
|
315
|
+
if dim not in graph.dimension_weights:
|
|
316
|
+
graph.dimension_weights[dim] = val
|
|
317
|
+
|
|
318
|
+
# Anti-preferences from persistent store
|
|
319
|
+
for anti in persisted.get("anti_preferences", []):
|
|
320
|
+
dim = anti.get("dimension", "")
|
|
321
|
+
direction = anti.get("direction", "")
|
|
322
|
+
if dim and dim not in graph.dimension_avoidances:
|
|
323
|
+
graph.dimension_avoidances[dim] = direction
|
|
324
|
+
|
|
325
|
+
# Attach persistent store for write-back
|
|
326
|
+
graph._persistent_store = persistent_store
|
|
327
|
+
|
|
261
328
|
return graph
|
|
@@ -189,12 +189,23 @@ def record_positive_preference(
|
|
|
189
189
|
not just what they dislike.
|
|
190
190
|
"""
|
|
191
191
|
taste_store = _get_taste_memory(ctx)
|
|
192
|
-
#
|
|
193
|
-
|
|
194
|
-
|
|
192
|
+
# Find matching outcome signals for this dimension+direction
|
|
193
|
+
from ..memory.taste_memory import _OUTCOME_SIGNALS
|
|
194
|
+
matching_signals = []
|
|
195
|
+
dim_signals = _OUTCOME_SIGNALS.get(dimension, {})
|
|
196
|
+
for sig_name, adjustment in dim_signals.items():
|
|
197
|
+
# "increase" preference → match positive-adjustment signals (kept)
|
|
198
|
+
# "decrease" preference → match negative-adjustment signals (undone/less)
|
|
199
|
+
if direction == "increase" and adjustment > 0:
|
|
200
|
+
matching_signals.append(sig_name)
|
|
201
|
+
elif direction == "decrease" and adjustment < 0:
|
|
202
|
+
matching_signals.append(sig_name)
|
|
203
|
+
if matching_signals:
|
|
204
|
+
taste_store.update_from_outcome({"signals": matching_signals})
|
|
195
205
|
return {
|
|
196
|
-
"recorded":
|
|
206
|
+
"recorded": bool(matching_signals),
|
|
197
207
|
"dimension": dimension,
|
|
198
208
|
"direction": direction,
|
|
209
|
+
"signals_matched": matching_signals,
|
|
199
210
|
"evidence": evidence,
|
|
200
211
|
}
|
|
@@ -96,9 +96,22 @@ def detect_repetition_fatigue(
|
|
|
96
96
|
# 3. Motif fatigue from motif_graph
|
|
97
97
|
if motif_graph:
|
|
98
98
|
motifs = motif_graph.get("motifs", [])
|
|
99
|
+
num_sections = max(1, len(scenes))
|
|
99
100
|
for motif in motifs:
|
|
100
101
|
fatigue_risk = motif.get("fatigue_risk", 0)
|
|
101
|
-
|
|
102
|
+
recurrence = motif.get("recurrence", 0)
|
|
103
|
+
|
|
104
|
+
# Motif appearing in >60% of sections = fatigue signal
|
|
105
|
+
if recurrence > 0.6 and num_sections >= 3:
|
|
106
|
+
adjusted_fatigue = max(fatigue_risk, recurrence * 0.8)
|
|
107
|
+
report.issues.append({
|
|
108
|
+
"type": "motif_overuse",
|
|
109
|
+
"severity": round(adjusted_fatigue, 3),
|
|
110
|
+
"detail": f"Motif {motif.get('name', motif.get('motif_id', '?'))} appears in {recurrence:.0%} of sections",
|
|
111
|
+
"motif_id": motif.get("motif_id", motif.get("name", "")),
|
|
112
|
+
"evidence": "motif_recurrence",
|
|
113
|
+
})
|
|
114
|
+
elif fatigue_risk > 0.6:
|
|
102
115
|
report.issues.append({
|
|
103
116
|
"type": "motif_overuse",
|
|
104
117
|
"severity": fatigue_risk,
|
|
@@ -45,10 +45,14 @@ def detect_repetition_fatigue(ctx: Context) -> dict:
|
|
|
45
45
|
"clips": row,
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
#
|
|
48
|
+
# Motif data — via shared motif service
|
|
49
49
|
motif_graph = None
|
|
50
50
|
try:
|
|
51
|
-
|
|
51
|
+
from ..services.motif_service import get_motif_data, fetch_notes_from_ableton
|
|
52
|
+
session_info = ableton.send_command("get_session_info", {})
|
|
53
|
+
track_list = session_info.get("tracks", [])
|
|
54
|
+
notes_by_track = fetch_notes_from_ableton(ableton, track_list)
|
|
55
|
+
motif_graph = get_motif_data(notes_by_track)
|
|
52
56
|
except Exception:
|
|
53
57
|
pass
|
|
54
58
|
|
|
@@ -171,17 +175,16 @@ def analyze_phrase_arc(
|
|
|
171
175
|
loudness_data = None
|
|
172
176
|
spectrum_data = None
|
|
173
177
|
|
|
178
|
+
# Direct Python calls to perception engine — not TCP
|
|
174
179
|
try:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
})
|
|
180
|
+
from ..tools._perception_engine import compute_loudness
|
|
181
|
+
loudness_data = compute_loudness(file_path, detail="full")
|
|
178
182
|
except Exception:
|
|
179
183
|
pass
|
|
180
184
|
|
|
181
185
|
try:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
})
|
|
186
|
+
from ..tools._perception_engine import compute_spectral
|
|
187
|
+
spectrum_data = compute_spectral(file_path)
|
|
185
188
|
except Exception:
|
|
186
189
|
pass
|
|
187
190
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Persistent storage for LivePilot state that survives server restart."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Persistent JSON store with atomic writes and corruption recovery.
|
|
2
|
+
|
|
3
|
+
Follows the TechniqueStore pattern: lazy init, atomic tmp+rename,
|
|
4
|
+
fsync to disk, corruption recovery via .corrupt rename.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PersistentJsonStore:
|
|
16
|
+
"""Thread-safe, crash-safe JSON file store."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path):
|
|
19
|
+
self._path = Path(path)
|
|
20
|
+
self._lock = threading.RLock()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def path(self) -> Path:
|
|
24
|
+
return self._path
|
|
25
|
+
|
|
26
|
+
def read(self) -> dict:
|
|
27
|
+
"""Read the store. Returns {} if missing or corrupt."""
|
|
28
|
+
with self._lock:
|
|
29
|
+
if not self._path.exists():
|
|
30
|
+
return {}
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
33
|
+
except (json.JSONDecodeError, OSError):
|
|
34
|
+
corrupt = self._path.with_suffix(self._path.suffix + ".corrupt")
|
|
35
|
+
try:
|
|
36
|
+
self._path.rename(corrupt)
|
|
37
|
+
except OSError:
|
|
38
|
+
pass
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
def write(self, data: dict) -> None:
|
|
42
|
+
"""Atomically write data to disk."""
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
tmp = self._path.with_suffix(".tmp")
|
|
46
|
+
try:
|
|
47
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(data, f, indent=2, default=str)
|
|
49
|
+
f.flush()
|
|
50
|
+
os.fsync(f.fileno())
|
|
51
|
+
os.replace(str(tmp), str(self._path))
|
|
52
|
+
except OSError:
|
|
53
|
+
try:
|
|
54
|
+
tmp.unlink(missing_ok=True)
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
def update(self, updater) -> dict:
|
|
60
|
+
"""Read-modify-write atomically. updater(data) -> modified data."""
|
|
61
|
+
with self._lock:
|
|
62
|
+
data = self._read_unlocked()
|
|
63
|
+
data = updater(data)
|
|
64
|
+
self._write_unlocked(data)
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
def _read_unlocked(self) -> dict:
|
|
68
|
+
if not self._path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(self._path.read_text(encoding="utf-8"))
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
def _write_unlocked(self, data: dict) -> None:
|
|
76
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
tmp = self._path.with_suffix(".tmp")
|
|
78
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
79
|
+
json.dump(data, f, indent=2, default=str)
|
|
80
|
+
f.flush()
|
|
81
|
+
os.fsync(f.fileno())
|
|
82
|
+
os.replace(str(tmp), str(self._path))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Per-project persistent state — threads, turns, Wonder outcomes.
|
|
2
|
+
|
|
3
|
+
Stores session continuity data scoped to a project identity.
|
|
4
|
+
Located at ~/.livepilot/projects/<hash>/state.json.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .base_store import PersistentJsonStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_PROJECTS_DIR = Path.home() / ".livepilot" / "projects"
|
|
18
|
+
_MAX_TURNS = 50
|
|
19
|
+
_MAX_WONDER_OUTCOMES = 10
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def project_hash(session_info: dict) -> str:
|
|
23
|
+
"""Compute a stable project fingerprint from session info.
|
|
24
|
+
|
|
25
|
+
Uses tempo + track count + sorted track names. This is imperfect
|
|
26
|
+
but stable enough for per-song state within a production session.
|
|
27
|
+
"""
|
|
28
|
+
tempo = session_info.get("tempo", 120.0)
|
|
29
|
+
tracks = session_info.get("tracks", [])
|
|
30
|
+
track_names = sorted(t.get("name", "") for t in tracks if isinstance(t, dict))
|
|
31
|
+
seed = f"{tempo:.1f}|{len(tracks)}|{'|'.join(track_names)}"
|
|
32
|
+
return hashlib.sha256(seed.encode()).hexdigest()[:12]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProjectStore:
|
|
36
|
+
"""Persistent per-project state."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, project_id: str, base_dir: Optional[Path] = None):
|
|
39
|
+
base = base_dir or _PROJECTS_DIR
|
|
40
|
+
self._store = PersistentJsonStore(base / project_id / "state.json")
|
|
41
|
+
self._project_id = project_id
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def project_id(self) -> str:
|
|
45
|
+
return self._project_id
|
|
46
|
+
|
|
47
|
+
def get_all(self) -> dict:
|
|
48
|
+
data = self._store.read()
|
|
49
|
+
return data if data.get("version") == 1 else self._default()
|
|
50
|
+
|
|
51
|
+
def save_thread(self, thread: dict) -> None:
|
|
52
|
+
"""Save or update a creative thread."""
|
|
53
|
+
def _update(data: dict) -> dict:
|
|
54
|
+
data = data if data.get("version") == 1 else self._default()
|
|
55
|
+
threads = data.setdefault("threads", [])
|
|
56
|
+
# Update existing or append
|
|
57
|
+
for i, t in enumerate(threads):
|
|
58
|
+
if t.get("thread_id") == thread.get("thread_id"):
|
|
59
|
+
threads[i] = thread
|
|
60
|
+
return data
|
|
61
|
+
threads.append(thread)
|
|
62
|
+
return data
|
|
63
|
+
self._store.update(_update)
|
|
64
|
+
|
|
65
|
+
def save_turn(self, turn: dict) -> None:
|
|
66
|
+
"""Save a turn resolution (capped at MAX_TURNS)."""
|
|
67
|
+
def _update(data: dict) -> dict:
|
|
68
|
+
data = data if data.get("version") == 1 else self._default()
|
|
69
|
+
turns = data.setdefault("turns", [])
|
|
70
|
+
turns.append(turn)
|
|
71
|
+
# Cap at max
|
|
72
|
+
if len(turns) > _MAX_TURNS:
|
|
73
|
+
data["turns"] = turns[-_MAX_TURNS:]
|
|
74
|
+
data["last_updated_ms"] = int(time.time() * 1000)
|
|
75
|
+
return data
|
|
76
|
+
self._store.update(_update)
|
|
77
|
+
|
|
78
|
+
def save_wonder_outcome(self, outcome: dict) -> None:
|
|
79
|
+
"""Save a Wonder session outcome (capped at MAX_WONDER_OUTCOMES)."""
|
|
80
|
+
def _update(data: dict) -> dict:
|
|
81
|
+
data = data if data.get("version") == 1 else self._default()
|
|
82
|
+
outcomes = data.setdefault("wonder_outcomes", [])
|
|
83
|
+
outcomes.append(outcome)
|
|
84
|
+
if len(outcomes) > _MAX_WONDER_OUTCOMES:
|
|
85
|
+
data["wonder_outcomes"] = outcomes[-_MAX_WONDER_OUTCOMES:]
|
|
86
|
+
return data
|
|
87
|
+
self._store.update(_update)
|
|
88
|
+
|
|
89
|
+
def get_threads(self) -> list[dict]:
|
|
90
|
+
return self.get_all().get("threads", [])
|
|
91
|
+
|
|
92
|
+
def get_turns(self) -> list[dict]:
|
|
93
|
+
return self.get_all().get("turns", [])
|
|
94
|
+
|
|
95
|
+
def get_wonder_outcomes(self) -> list[dict]:
|
|
96
|
+
return self.get_all().get("wonder_outcomes", [])
|
|
97
|
+
|
|
98
|
+
@staticmethod
|
|
99
|
+
def _default() -> dict:
|
|
100
|
+
return {
|
|
101
|
+
"version": 1,
|
|
102
|
+
"threads": [],
|
|
103
|
+
"turns": [],
|
|
104
|
+
"wonder_outcomes": [],
|
|
105
|
+
"last_updated_ms": 0,
|
|
106
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Persistent taste state — survives server restart.
|
|
2
|
+
|
|
3
|
+
Stores move outcomes, novelty preference, device affinity,
|
|
4
|
+
anti-preferences, and dimension weights. Located at
|
|
5
|
+
~/.livepilot/taste.json.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .base_store import PersistentJsonStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_DEFAULT_PATH = Path.home() / ".livepilot" / "taste.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PersistentTasteStore:
|
|
21
|
+
"""Persistent backing for TasteGraph data."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, path: Optional[Path] = None):
|
|
24
|
+
self._store = PersistentJsonStore(path or _DEFAULT_PATH)
|
|
25
|
+
|
|
26
|
+
def get_all(self) -> dict:
|
|
27
|
+
"""Get all persisted taste data."""
|
|
28
|
+
data = self._store.read()
|
|
29
|
+
return data if data.get("version") == 1 else self._default()
|
|
30
|
+
|
|
31
|
+
def record_move_outcome(
|
|
32
|
+
self, move_id: str, family: str, kept: bool, score: float = 0.0,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Persist a move outcome."""
|
|
35
|
+
def _update(data: dict) -> dict:
|
|
36
|
+
data = data if data.get("version") == 1 else self._default()
|
|
37
|
+
outcomes = data.setdefault("move_outcomes", {})
|
|
38
|
+
entry = outcomes.setdefault(move_id, {
|
|
39
|
+
"family": family, "kept_count": 0, "undone_count": 0,
|
|
40
|
+
})
|
|
41
|
+
entry["family"] = family
|
|
42
|
+
if kept:
|
|
43
|
+
entry["kept_count"] = entry.get("kept_count", 0) + 1
|
|
44
|
+
else:
|
|
45
|
+
entry["undone_count"] = entry.get("undone_count", 0) + 1
|
|
46
|
+
data["evidence_count"] = data.get("evidence_count", 0) + 1
|
|
47
|
+
data["last_updated_ms"] = int(time.time() * 1000)
|
|
48
|
+
return data
|
|
49
|
+
self._store.update(_update)
|
|
50
|
+
|
|
51
|
+
def update_novelty(self, chose_bold: bool) -> None:
|
|
52
|
+
"""Update novelty band from experiment choice."""
|
|
53
|
+
def _update(data: dict) -> dict:
|
|
54
|
+
data = data if data.get("version") == 1 else self._default()
|
|
55
|
+
band = data.get("novelty_band", 0.5)
|
|
56
|
+
if chose_bold:
|
|
57
|
+
data["novelty_band"] = min(1.0, band + 0.05)
|
|
58
|
+
else:
|
|
59
|
+
data["novelty_band"] = max(0.0, band - 0.05)
|
|
60
|
+
data["evidence_count"] = data.get("evidence_count", 0) + 1
|
|
61
|
+
return data
|
|
62
|
+
self._store.update(_update)
|
|
63
|
+
|
|
64
|
+
def record_device_use(self, device_name: str, positive: bool = True) -> None:
|
|
65
|
+
"""Persist device affinity."""
|
|
66
|
+
def _update(data: dict) -> dict:
|
|
67
|
+
data = data if data.get("version") == 1 else self._default()
|
|
68
|
+
affinities = data.setdefault("device_affinities", {})
|
|
69
|
+
entry = affinities.setdefault(device_name, {
|
|
70
|
+
"affinity": 0.0, "use_count": 0,
|
|
71
|
+
})
|
|
72
|
+
entry["use_count"] = entry.get("use_count", 0) + 1
|
|
73
|
+
aff = entry.get("affinity", 0.0)
|
|
74
|
+
if positive:
|
|
75
|
+
entry["affinity"] = min(1.0, aff + 0.05)
|
|
76
|
+
else:
|
|
77
|
+
entry["affinity"] = max(-1.0, aff - 0.08)
|
|
78
|
+
data["evidence_count"] = data.get("evidence_count", 0) + 1
|
|
79
|
+
return data
|
|
80
|
+
self._store.update(_update)
|
|
81
|
+
|
|
82
|
+
def record_anti_preference(self, dimension: str, direction: str) -> None:
|
|
83
|
+
"""Persist an anti-preference."""
|
|
84
|
+
def _update(data: dict) -> dict:
|
|
85
|
+
data = data if data.get("version") == 1 else self._default()
|
|
86
|
+
antis = data.setdefault("anti_preferences", [])
|
|
87
|
+
existing = next(
|
|
88
|
+
(a for a in antis if a["dimension"] == dimension and a["direction"] == direction),
|
|
89
|
+
None,
|
|
90
|
+
)
|
|
91
|
+
if existing:
|
|
92
|
+
existing["count"] = existing.get("count", 0) + 1
|
|
93
|
+
existing["strength"] = min(1.0, existing["count"] * 0.2)
|
|
94
|
+
else:
|
|
95
|
+
antis.append({
|
|
96
|
+
"dimension": dimension, "direction": direction,
|
|
97
|
+
"count": 1, "strength": 0.2,
|
|
98
|
+
})
|
|
99
|
+
data["evidence_count"] = data.get("evidence_count", 0) + 1
|
|
100
|
+
return data
|
|
101
|
+
self._store.update(_update)
|
|
102
|
+
|
|
103
|
+
def record_dimension_weight(self, dimension: str, value: float) -> None:
|
|
104
|
+
"""Persist a dimension weight update."""
|
|
105
|
+
def _update(data: dict) -> dict:
|
|
106
|
+
data = data if data.get("version") == 1 else self._default()
|
|
107
|
+
data.setdefault("dimension_weights", {})[dimension] = round(value, 3)
|
|
108
|
+
return data
|
|
109
|
+
self._store.update(_update)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def _default() -> dict:
|
|
113
|
+
return {
|
|
114
|
+
"version": 1,
|
|
115
|
+
"move_outcomes": {},
|
|
116
|
+
"novelty_band": 0.5,
|
|
117
|
+
"device_affinities": {},
|
|
118
|
+
"anti_preferences": [],
|
|
119
|
+
"dimension_weights": {},
|
|
120
|
+
"evidence_count": 0,
|
|
121
|
+
"last_updated_ms": 0,
|
|
122
|
+
}
|
|
@@ -154,15 +154,20 @@ def create_preview_set(
|
|
|
154
154
|
import sys
|
|
155
155
|
print(f"LivePilot: SongBrain unavailable in preview_studio: {_e}", file=sys.stderr)
|
|
156
156
|
|
|
157
|
-
# Get taste graph —
|
|
157
|
+
# Get taste graph — session + persistent stores
|
|
158
158
|
taste_graph: dict = {}
|
|
159
159
|
try:
|
|
160
160
|
from ..memory.taste_graph import build_taste_graph
|
|
161
161
|
from ..memory.taste_memory import TasteMemoryStore
|
|
162
162
|
from ..memory.anti_memory import AntiMemoryStore
|
|
163
|
+
from ..persistence.taste_store import PersistentTasteStore
|
|
163
164
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
164
165
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
165
|
-
|
|
166
|
+
persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
|
|
167
|
+
graph = build_taste_graph(
|
|
168
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
169
|
+
persistent_store=persistent,
|
|
170
|
+
)
|
|
166
171
|
taste_graph = graph.to_dict()
|
|
167
172
|
except Exception:
|
|
168
173
|
pass
|
|
@@ -269,14 +274,19 @@ def commit_preview_variant(
|
|
|
269
274
|
except Exception:
|
|
270
275
|
pass
|
|
271
276
|
|
|
272
|
-
# Update taste graph
|
|
277
|
+
# Update taste graph (with persistent backing)
|
|
273
278
|
try:
|
|
274
279
|
from ..memory.taste_graph import build_taste_graph
|
|
275
280
|
from ..memory.taste_memory import TasteMemoryStore
|
|
276
281
|
from ..memory.anti_memory import AntiMemoryStore
|
|
282
|
+
from ..persistence.taste_store import PersistentTasteStore
|
|
277
283
|
taste_store = ctx.lifespan_context.setdefault("taste_memory", TasteMemoryStore())
|
|
278
284
|
anti_store = ctx.lifespan_context.setdefault("anti_memory", AntiMemoryStore())
|
|
279
|
-
|
|
285
|
+
persistent = ctx.lifespan_context.setdefault("persistent_taste", PersistentTasteStore())
|
|
286
|
+
graph = build_taste_graph(
|
|
287
|
+
taste_store=taste_store, anti_store=anti_store,
|
|
288
|
+
persistent_store=persistent,
|
|
289
|
+
)
|
|
280
290
|
# Look up family from WonderSession's variant list
|
|
281
291
|
family = ""
|
|
282
292
|
for v in ws.variants:
|
|
@@ -349,18 +359,17 @@ def render_preview_variant(
|
|
|
349
359
|
# compiled_plan may be a list (from semantic moves) or a dict with "steps" key
|
|
350
360
|
plan = variant.compiled_plan
|
|
351
361
|
steps = plan if isinstance(plan, list) else plan.get("steps", [])
|
|
362
|
+
|
|
363
|
+
from ..runtime.execution_router import execute_plan_steps
|
|
364
|
+
|
|
352
365
|
applied_count = 0
|
|
353
366
|
try:
|
|
354
367
|
# Capture before state
|
|
355
368
|
before_info = ableton.send_command("get_session_info", {})
|
|
356
369
|
|
|
357
|
-
#
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
args = step.get("params") or step.get("args", {})
|
|
361
|
-
if cmd:
|
|
362
|
-
ableton.send_command(cmd, args)
|
|
363
|
-
applied_count += 1
|
|
370
|
+
# Execute through unified router
|
|
371
|
+
exec_results = execute_plan_steps(steps, ableton=ableton, ctx=ctx)
|
|
372
|
+
applied_count = sum(1 for r in exec_results if r.ok)
|
|
364
373
|
|
|
365
374
|
# Capture after state
|
|
366
375
|
after_info = ableton.send_command("get_session_info", {})
|
|
@@ -374,29 +383,63 @@ def render_preview_variant(
|
|
|
374
383
|
except Exception:
|
|
375
384
|
break
|
|
376
385
|
|
|
386
|
+
# Determine preview mode: audible (M4L available) or metadata-only
|
|
387
|
+
preview_mode = "metadata_only_preview"
|
|
388
|
+
spectral_before = None
|
|
389
|
+
spectral_after = None
|
|
390
|
+
|
|
391
|
+
# Try audible preview — capture spectrum via M4L spectral cache
|
|
392
|
+
try:
|
|
393
|
+
from ..m4l_bridge import SpectralCache
|
|
394
|
+
cache = ctx.lifespan_context.get("spectral")
|
|
395
|
+
if cache and isinstance(cache, SpectralCache) and cache.is_connected:
|
|
396
|
+
spectral_before = cache.get_all()
|
|
397
|
+
# Play for the requested bar count
|
|
398
|
+
tempo = before_info.get("tempo", 120)
|
|
399
|
+
play_seconds = bars * (60.0 / tempo) * 4 # bars * beat_duration * 4 beats
|
|
400
|
+
ableton.send_command("start_playback", {})
|
|
401
|
+
import time as _time
|
|
402
|
+
_time.sleep(min(play_seconds, 8.0)) # cap at 8 seconds
|
|
403
|
+
spectral_after = cache.get_all()
|
|
404
|
+
ableton.send_command("stop_playback", {})
|
|
405
|
+
preview_mode = "audible_preview"
|
|
406
|
+
except Exception:
|
|
407
|
+
pass # fall back to metadata_only
|
|
408
|
+
|
|
377
409
|
variant.status = "rendered"
|
|
410
|
+
variant.preview_mode = preview_mode
|
|
378
411
|
variant.render_ref = f"render_{variant_id}_{bars}bars"
|
|
379
412
|
|
|
380
|
-
|
|
413
|
+
result = {
|
|
381
414
|
"rendered": True,
|
|
382
415
|
"variant_id": variant_id,
|
|
383
416
|
"label": variant.label,
|
|
384
417
|
"bars": bars,
|
|
418
|
+
"preview_mode": preview_mode,
|
|
385
419
|
"before_summary": {"tempo": before_info.get("tempo"), "tracks": before_info.get("track_count")},
|
|
386
420
|
"after_summary": {"tempo": after_info.get("tempo"), "tracks": after_info.get("track_count")},
|
|
387
421
|
"identity_effect": variant.identity_effect,
|
|
388
422
|
"what_changed": variant.what_changed,
|
|
389
423
|
"what_preserved": variant.what_preserved,
|
|
390
424
|
}
|
|
425
|
+
|
|
426
|
+
if spectral_before and spectral_after:
|
|
427
|
+
result["spectral_comparison"] = {
|
|
428
|
+
"before": spectral_before,
|
|
429
|
+
"after": spectral_after,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return result
|
|
391
433
|
else:
|
|
392
434
|
# Analytical preview — no live render
|
|
393
435
|
variant.status = "rendered"
|
|
436
|
+
variant.preview_mode = "analytical_preview"
|
|
394
437
|
return {
|
|
395
438
|
"rendered": True,
|
|
396
439
|
"variant_id": variant_id,
|
|
397
440
|
"label": variant.label,
|
|
398
441
|
"bars": bars,
|
|
399
|
-
"
|
|
442
|
+
"preview_mode": "analytical_preview",
|
|
400
443
|
"intent": variant.intent,
|
|
401
444
|
"novelty_level": variant.novelty_level,
|
|
402
445
|
"identity_effect": variant.identity_effect,
|