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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Prompt parser — natural language → structured CompositionIntent.
|
|
2
|
+
|
|
3
|
+
Extracts genre, mood, tempo, key, descriptors, and explicit element requests
|
|
4
|
+
from free-form text prompts. Pure computation, no I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ── Data Model ─────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class CompositionIntent:
|
|
18
|
+
"""Structured representation of a composition request."""
|
|
19
|
+
|
|
20
|
+
genre: str = ""
|
|
21
|
+
sub_genre: str = ""
|
|
22
|
+
mood: str = ""
|
|
23
|
+
tempo: int = 0 # 0 = auto-detect from genre
|
|
24
|
+
key: str = "" # "" = auto-pick based on mood
|
|
25
|
+
descriptors: list[str] = field(default_factory=list)
|
|
26
|
+
explicit_elements: list[str] = field(default_factory=list)
|
|
27
|
+
energy: float = 0.5 # 0.0-1.0
|
|
28
|
+
layer_count: int = 0 # 0 = auto (genre determines)
|
|
29
|
+
duration_bars: int = 64 # total arrangement length
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
return {
|
|
33
|
+
"genre": self.genre,
|
|
34
|
+
"sub_genre": self.sub_genre,
|
|
35
|
+
"mood": self.mood,
|
|
36
|
+
"tempo": self.tempo,
|
|
37
|
+
"key": self.key,
|
|
38
|
+
"descriptors": self.descriptors,
|
|
39
|
+
"explicit_elements": self.explicit_elements,
|
|
40
|
+
"energy": self.energy,
|
|
41
|
+
"layer_count": self.layer_count,
|
|
42
|
+
"duration_bars": self.duration_bars,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Genre Defaults ─────────────────────────────────────────────────
|
|
47
|
+
# genre → (default_tempo, default_keys, default_energy, layer_range)
|
|
48
|
+
|
|
49
|
+
GENRE_DEFAULTS: dict[str, dict] = {
|
|
50
|
+
"techno": {
|
|
51
|
+
"tempo": 128, "keys": ["Am", "Cm"], "energy": 0.7,
|
|
52
|
+
"layers_min": 5, "layers_max": 7,
|
|
53
|
+
},
|
|
54
|
+
"house": {
|
|
55
|
+
"tempo": 124, "keys": ["Cm", "Fm"], "energy": 0.6,
|
|
56
|
+
"layers_min": 5, "layers_max": 6,
|
|
57
|
+
},
|
|
58
|
+
"hip hop": {
|
|
59
|
+
"tempo": 90, "keys": ["Cm", "Gm"], "energy": 0.5,
|
|
60
|
+
"layers_min": 4, "layers_max": 6,
|
|
61
|
+
},
|
|
62
|
+
"ambient": {
|
|
63
|
+
"tempo": 80, "keys": ["C", "Am"], "energy": 0.2,
|
|
64
|
+
"layers_min": 3, "layers_max": 5,
|
|
65
|
+
},
|
|
66
|
+
"drum and bass": {
|
|
67
|
+
"tempo": 174, "keys": ["Am", "Em"], "energy": 0.8,
|
|
68
|
+
"layers_min": 5, "layers_max": 7,
|
|
69
|
+
},
|
|
70
|
+
"trap": {
|
|
71
|
+
"tempo": 140, "keys": ["Cm", "Bbm"], "energy": 0.6,
|
|
72
|
+
"layers_min": 4, "layers_max": 6,
|
|
73
|
+
},
|
|
74
|
+
"lo-fi": {
|
|
75
|
+
"tempo": 85, "keys": ["Fm", "Cm"], "energy": 0.3,
|
|
76
|
+
"layers_min": 3, "layers_max": 5,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Aliases that map to canonical genre names
|
|
81
|
+
_GENRE_ALIASES: dict[str, str] = {
|
|
82
|
+
"dnb": "drum and bass",
|
|
83
|
+
"d&b": "drum and bass",
|
|
84
|
+
"jungle": "drum and bass",
|
|
85
|
+
"lofi": "lo-fi",
|
|
86
|
+
"lo fi": "lo-fi",
|
|
87
|
+
"hiphop": "hip hop",
|
|
88
|
+
"hip-hop": "hip hop",
|
|
89
|
+
"deep house": "house",
|
|
90
|
+
"tech house": "house",
|
|
91
|
+
"acid techno": "techno",
|
|
92
|
+
"hard techno": "techno",
|
|
93
|
+
"industrial techno": "techno",
|
|
94
|
+
"minimal techno": "techno",
|
|
95
|
+
"detroit techno": "techno",
|
|
96
|
+
"dub techno": "techno",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Mood Mapping ───────────────────────────────────────────────────
|
|
101
|
+
# mood → (energy_range, key_bias_list)
|
|
102
|
+
|
|
103
|
+
MOOD_MAPPING: dict[str, dict] = {
|
|
104
|
+
"dark": {
|
|
105
|
+
"energy_min": 0.4, "energy_max": 0.6,
|
|
106
|
+
"key_bias": ["Am", "Cm", "Em", "Dm"],
|
|
107
|
+
},
|
|
108
|
+
"euphoric": {
|
|
109
|
+
"energy_min": 0.8, "energy_max": 1.0,
|
|
110
|
+
"key_bias": ["C", "G", "F", "A"],
|
|
111
|
+
},
|
|
112
|
+
"melancholic": {
|
|
113
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
114
|
+
"key_bias": ["Fm", "Cm", "Dm", "Bbm"],
|
|
115
|
+
},
|
|
116
|
+
"aggressive": {
|
|
117
|
+
"energy_min": 0.8, "energy_max": 0.9,
|
|
118
|
+
"key_bias": ["Am", "Em", "Bm", "F#m"],
|
|
119
|
+
},
|
|
120
|
+
"dreamy": {
|
|
121
|
+
"energy_min": 0.2, "energy_max": 0.3,
|
|
122
|
+
"key_bias": ["C", "F", "Bb", "Eb"],
|
|
123
|
+
},
|
|
124
|
+
"chill": {
|
|
125
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
126
|
+
"key_bias": ["Fm", "Cm", "Gm", "Dm"],
|
|
127
|
+
},
|
|
128
|
+
"hypnotic": {
|
|
129
|
+
"energy_min": 0.5, "energy_max": 0.7,
|
|
130
|
+
"key_bias": ["Am", "Em", "Dm"],
|
|
131
|
+
},
|
|
132
|
+
"ethereal": {
|
|
133
|
+
"energy_min": 0.2, "energy_max": 0.4,
|
|
134
|
+
"key_bias": ["C", "F", "Ab", "Eb"],
|
|
135
|
+
},
|
|
136
|
+
"driving": {
|
|
137
|
+
"energy_min": 0.7, "energy_max": 0.9,
|
|
138
|
+
"key_bias": ["Am", "Em", "Cm"],
|
|
139
|
+
},
|
|
140
|
+
"warm": {
|
|
141
|
+
"energy_min": 0.3, "energy_max": 0.5,
|
|
142
|
+
"key_bias": ["F", "Bb", "Eb", "Ab"],
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Sub-genre keywords ─────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
_SUB_GENRE_KEYWORDS: list[str] = [
|
|
150
|
+
"minimal", "deep", "acid", "industrial", "detroit", "dub",
|
|
151
|
+
"progressive", "melodic", "hard", "dark", "atmospheric",
|
|
152
|
+
"organic", "analog", "modular", "breakbeat", "uk garage",
|
|
153
|
+
"2-step", "drill", "boom bap", "old school", "new wave",
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Descriptor keywords (adjectives that color the composition) ────
|
|
158
|
+
|
|
159
|
+
_DESCRIPTOR_KEYWORDS: list[str] = [
|
|
160
|
+
"industrial", "ghostly", "warm", "cold", "metallic", "organic",
|
|
161
|
+
"spacious", "intimate", "raw", "polished", "gritty", "clean",
|
|
162
|
+
"distorted", "saturated", "lush", "sparse", "dense", "airy",
|
|
163
|
+
"punchy", "soft", "crisp", "muddy", "bright", "muted",
|
|
164
|
+
"psychedelic", "glitchy", "cinematic", "underground", "futuristic",
|
|
165
|
+
"retro", "vintage", "modern", "classic", "experimental",
|
|
166
|
+
]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Element extraction patterns ────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
_ELEMENT_PATTERNS: list[tuple[str, str]] = [
|
|
172
|
+
# (regex_pattern, element_name)
|
|
173
|
+
(r"\bwith\s+vocals?\b", "vocal"),
|
|
174
|
+
(r"\bwith\s+strings?\b", "strings"),
|
|
175
|
+
(r"\badd\s+strings?\b", "strings"),
|
|
176
|
+
(r"\b808\s*bass\b", "808"),
|
|
177
|
+
(r"\bwith\s+808\b", "808"),
|
|
178
|
+
(r"\bwith\s+synth\b", "synth"),
|
|
179
|
+
(r"\bwith\s+pads?\b", "pad"),
|
|
180
|
+
(r"\bwith\s+piano\b", "piano"),
|
|
181
|
+
(r"\bwith\s+guitar\b", "guitar"),
|
|
182
|
+
(r"\bwith\s+brass\b", "brass"),
|
|
183
|
+
(r"\bwith\s+horns?\b", "brass"),
|
|
184
|
+
(r"\bwith\s+(?:fx|effects?)\b", "fx"),
|
|
185
|
+
(r"\bwith\s+risers?\b", "fx"),
|
|
186
|
+
(r"\bwith\s+(?:perc|percussion)\b", "percussion"),
|
|
187
|
+
(r"\bwith\s+textures?\b", "texture"),
|
|
188
|
+
(r"\bghostly\s+vocals?\b", "vocal"),
|
|
189
|
+
(r"\bvocal\s+chops?\b", "vocal"),
|
|
190
|
+
(r"\bvocal\s+stabs?\b", "vocal"),
|
|
191
|
+
(r"\bsub\s*bass\b", "bass"),
|
|
192
|
+
(r"\breese\s*bass\b", "bass"),
|
|
193
|
+
(r"\bamen\s+break\b", "drums"),
|
|
194
|
+
(r"\bbreakbeat\b", "drums"),
|
|
195
|
+
(r"\bfoley\b", "texture"),
|
|
196
|
+
(r"\bfield\s+recordings?\b", "texture"),
|
|
197
|
+
(r"\batmospheric\b", "texture"),
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ── Regex helpers ──────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
_TEMPO_RE = re.compile(r"\b(\d{2,3})\s*bpm\b", re.IGNORECASE)
|
|
204
|
+
|
|
205
|
+
# Key patterns: C, Cm, C#, C# minor, Db, Dbm, F# minor, Bb major
|
|
206
|
+
_KEY_RE = re.compile(
|
|
207
|
+
r"\b([A-Ga-g][#b]?)\s*(minor|major|min|maj|m)?\b"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Parser ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def parse_prompt(text: str) -> CompositionIntent:
|
|
214
|
+
"""Parse a natural language composition prompt into structured intent.
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
"dark minimal techno 128bpm Cm"
|
|
218
|
+
"euphoric deep house with vocals"
|
|
219
|
+
"lo-fi hip hop 85bpm F minor dreamy"
|
|
220
|
+
"aggressive drum and bass 174bpm Am"
|
|
221
|
+
"""
|
|
222
|
+
intent = CompositionIntent()
|
|
223
|
+
text_lower = text.lower().strip()
|
|
224
|
+
|
|
225
|
+
# 1. Extract tempo
|
|
226
|
+
tempo_match = _TEMPO_RE.search(text)
|
|
227
|
+
if tempo_match:
|
|
228
|
+
intent.tempo = int(tempo_match.group(1))
|
|
229
|
+
|
|
230
|
+
# 2. Extract key (search original text to preserve case)
|
|
231
|
+
key_match = _KEY_RE.search(text)
|
|
232
|
+
if key_match:
|
|
233
|
+
root = key_match.group(1)
|
|
234
|
+
# Normalize root: uppercase first letter
|
|
235
|
+
root = root[0].upper() + root[1:] if len(root) > 1 else root.upper()
|
|
236
|
+
quality = key_match.group(2) or ""
|
|
237
|
+
quality_lower = quality.lower()
|
|
238
|
+
if quality_lower in ("minor", "min", "m"):
|
|
239
|
+
intent.key = f"{root}m"
|
|
240
|
+
elif quality_lower in ("major", "maj"):
|
|
241
|
+
intent.key = root
|
|
242
|
+
else:
|
|
243
|
+
# Standalone note — check if followed by 'm' in the original
|
|
244
|
+
intent.key = root
|
|
245
|
+
|
|
246
|
+
# 3. Match genre (check aliases first, then canonical names)
|
|
247
|
+
# Sort by length descending to match longer aliases first
|
|
248
|
+
all_genres = list(_GENRE_ALIASES.items()) + [
|
|
249
|
+
(g, g) for g in GENRE_DEFAULTS
|
|
250
|
+
]
|
|
251
|
+
all_genres.sort(key=lambda x: -len(x[0]))
|
|
252
|
+
|
|
253
|
+
for alias, canonical in all_genres:
|
|
254
|
+
if alias in text_lower:
|
|
255
|
+
intent.genre = canonical
|
|
256
|
+
# Extract sub-genre from the alias if it differs
|
|
257
|
+
if alias != canonical and " " in alias:
|
|
258
|
+
parts = alias.split()
|
|
259
|
+
for part in parts:
|
|
260
|
+
if part != canonical and part in text_lower:
|
|
261
|
+
intent.sub_genre = part
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
# 4. Check for sub-genre keywords not caught by alias matching
|
|
265
|
+
if not intent.sub_genre:
|
|
266
|
+
for kw in _SUB_GENRE_KEYWORDS:
|
|
267
|
+
if kw in text_lower and kw != intent.genre:
|
|
268
|
+
intent.sub_genre = kw
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
# 5. Match mood
|
|
272
|
+
for mood_name in MOOD_MAPPING:
|
|
273
|
+
if mood_name in text_lower:
|
|
274
|
+
intent.mood = mood_name
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
# 6. Extract descriptors
|
|
278
|
+
for descriptor in _DESCRIPTOR_KEYWORDS:
|
|
279
|
+
if descriptor in text_lower and descriptor != intent.mood and descriptor != intent.sub_genre:
|
|
280
|
+
intent.descriptors.append(descriptor)
|
|
281
|
+
|
|
282
|
+
# 7. Extract explicit elements
|
|
283
|
+
seen_elements: set[str] = set()
|
|
284
|
+
for pattern, element in _ELEMENT_PATTERNS:
|
|
285
|
+
if re.search(pattern, text_lower) and element not in seen_elements:
|
|
286
|
+
intent.explicit_elements.append(element)
|
|
287
|
+
seen_elements.add(element)
|
|
288
|
+
|
|
289
|
+
# 8. Apply genre defaults for missing fields
|
|
290
|
+
genre_info = GENRE_DEFAULTS.get(intent.genre, {})
|
|
291
|
+
|
|
292
|
+
if intent.tempo == 0 and genre_info:
|
|
293
|
+
intent.tempo = genre_info["tempo"]
|
|
294
|
+
|
|
295
|
+
if not intent.key:
|
|
296
|
+
# Use mood bias if available, otherwise genre default
|
|
297
|
+
if intent.mood and intent.mood in MOOD_MAPPING:
|
|
298
|
+
intent.key = MOOD_MAPPING[intent.mood]["key_bias"][0]
|
|
299
|
+
elif genre_info:
|
|
300
|
+
intent.key = genre_info["keys"][0]
|
|
301
|
+
|
|
302
|
+
# 9. Compute energy from mood, fallback to genre
|
|
303
|
+
if intent.mood and intent.mood in MOOD_MAPPING:
|
|
304
|
+
mood_info = MOOD_MAPPING[intent.mood]
|
|
305
|
+
intent.energy = (mood_info["energy_min"] + mood_info["energy_max"]) / 2.0
|
|
306
|
+
elif genre_info:
|
|
307
|
+
intent.energy = genre_info["energy"]
|
|
308
|
+
else:
|
|
309
|
+
intent.energy = 0.5
|
|
310
|
+
|
|
311
|
+
# 10. Determine layer count from genre + energy
|
|
312
|
+
if intent.layer_count == 0 and genre_info:
|
|
313
|
+
base_min = genre_info["layers_min"]
|
|
314
|
+
base_max = genre_info["layers_max"]
|
|
315
|
+
# Higher energy → more layers
|
|
316
|
+
energy_factor = intent.energy
|
|
317
|
+
intent.layer_count = round(
|
|
318
|
+
base_min + (base_max - base_min) * energy_factor
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Fallback defaults
|
|
322
|
+
if intent.tempo == 0:
|
|
323
|
+
intent.tempo = 120
|
|
324
|
+
if not intent.key:
|
|
325
|
+
intent.key = "Am"
|
|
326
|
+
if intent.layer_count == 0:
|
|
327
|
+
intent.layer_count = 5
|
|
328
|
+
|
|
329
|
+
return intent
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Composer Engine MCP tools — 3 tools for auto-composition.
|
|
2
|
+
|
|
3
|
+
compose: full multi-layer composition from text prompt
|
|
4
|
+
augment_with_samples: add layers to existing session
|
|
5
|
+
get_composition_plan: dry run preview
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context
|
|
13
|
+
|
|
14
|
+
from ..server import mcp
|
|
15
|
+
from .prompt_parser import parse_prompt
|
|
16
|
+
from .engine import ComposerEngine
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Singleton engine — stateless, safe to reuse
|
|
20
|
+
_engine = ComposerEngine()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool()
|
|
24
|
+
async def compose(
|
|
25
|
+
ctx: Context,
|
|
26
|
+
prompt: str,
|
|
27
|
+
max_credits: int = 50,
|
|
28
|
+
dry_run: bool = False,
|
|
29
|
+
) -> dict:
|
|
30
|
+
"""Create a full multi-layer composition from a text prompt.
|
|
31
|
+
|
|
32
|
+
Searches Splice's catalog, selects matching samples with critic scoring,
|
|
33
|
+
downloads them, loads into Ableton, applies processing techniques, and
|
|
34
|
+
arranges into genre-appropriate sections.
|
|
35
|
+
|
|
36
|
+
prompt: "dark minimal techno 128bpm with industrial textures and ghostly vocals"
|
|
37
|
+
max_credits: maximum Splice credits to spend (default 50, 0 = use only downloaded)
|
|
38
|
+
dry_run: if True, return the plan without executing (same as get_composition_plan)
|
|
39
|
+
|
|
40
|
+
Returns a compiled plan with all execution steps. When dry_run is False,
|
|
41
|
+
the plan is ready for step-by-step execution by the agent.
|
|
42
|
+
"""
|
|
43
|
+
# Parse the prompt into structured intent
|
|
44
|
+
intent = parse_prompt(prompt)
|
|
45
|
+
|
|
46
|
+
# Credit safety check
|
|
47
|
+
splice_client = None
|
|
48
|
+
credits_remaining = None
|
|
49
|
+
try:
|
|
50
|
+
lifespan = ctx.lifespan_context
|
|
51
|
+
if lifespan and "splice" in lifespan:
|
|
52
|
+
splice_client = lifespan["splice"]
|
|
53
|
+
if splice_client and splice_client.connected:
|
|
54
|
+
credits_remaining = await splice_client.get_credits_remaining()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
warnings: list[str] = []
|
|
59
|
+
|
|
60
|
+
if credits_remaining is not None:
|
|
61
|
+
if credits_remaining <= 5:
|
|
62
|
+
warnings.append(
|
|
63
|
+
f"Splice credits critically low ({credits_remaining}). "
|
|
64
|
+
f"Using downloaded samples only."
|
|
65
|
+
)
|
|
66
|
+
max_credits = 0
|
|
67
|
+
elif max_credits > credits_remaining - 5:
|
|
68
|
+
safe_budget = max(0, credits_remaining - 5)
|
|
69
|
+
warnings.append(
|
|
70
|
+
f"Budget capped at {safe_budget} credits "
|
|
71
|
+
f"(remaining: {credits_remaining}, floor: 5)."
|
|
72
|
+
)
|
|
73
|
+
max_credits = safe_budget
|
|
74
|
+
|
|
75
|
+
if splice_client is None or not getattr(splice_client, "connected", False):
|
|
76
|
+
warnings.append(
|
|
77
|
+
"Splice not connected. Plan will use browser/filesystem fallback "
|
|
78
|
+
"for sample search."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Compose
|
|
82
|
+
result = _engine.compose(intent, dry_run=dry_run, max_credits=max_credits)
|
|
83
|
+
|
|
84
|
+
# Merge warnings
|
|
85
|
+
result.warnings.extend(warnings)
|
|
86
|
+
|
|
87
|
+
output = result.to_dict()
|
|
88
|
+
output["prompt"] = prompt
|
|
89
|
+
|
|
90
|
+
if credits_remaining is not None:
|
|
91
|
+
output["credits_remaining"] = credits_remaining
|
|
92
|
+
output["credits_budget"] = max_credits
|
|
93
|
+
|
|
94
|
+
return output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
async def augment_with_samples(
|
|
99
|
+
ctx: Context,
|
|
100
|
+
request: str,
|
|
101
|
+
max_credits: int = 10,
|
|
102
|
+
max_layers: int = 3,
|
|
103
|
+
) -> dict:
|
|
104
|
+
"""Add sample-based layers to the existing session.
|
|
105
|
+
|
|
106
|
+
Analyzes the request, searches Splice for complementary samples,
|
|
107
|
+
and creates a plan to add new tracks with appropriate processing.
|
|
108
|
+
|
|
109
|
+
request: "add organic textures" or "layer a vocal chop over the verse"
|
|
110
|
+
max_credits: maximum Splice credits to spend (default 10)
|
|
111
|
+
max_layers: maximum number of new tracks to add (default 3)
|
|
112
|
+
|
|
113
|
+
Returns a compiled plan for adding new layers to the session.
|
|
114
|
+
"""
|
|
115
|
+
# Credit safety
|
|
116
|
+
splice_client = None
|
|
117
|
+
credits_remaining = None
|
|
118
|
+
try:
|
|
119
|
+
lifespan = ctx.lifespan_context
|
|
120
|
+
if lifespan and "splice" in lifespan:
|
|
121
|
+
splice_client = lifespan["splice"]
|
|
122
|
+
if splice_client and splice_client.connected:
|
|
123
|
+
credits_remaining = await splice_client.get_credits_remaining()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
warnings: list[str] = []
|
|
128
|
+
|
|
129
|
+
if credits_remaining is not None:
|
|
130
|
+
if credits_remaining <= 5:
|
|
131
|
+
warnings.append(
|
|
132
|
+
f"Splice credits critically low ({credits_remaining}). "
|
|
133
|
+
f"Using downloaded samples only."
|
|
134
|
+
)
|
|
135
|
+
max_credits = 0
|
|
136
|
+
elif max_credits > credits_remaining - 5:
|
|
137
|
+
safe_budget = max(0, credits_remaining - 5)
|
|
138
|
+
max_credits = safe_budget
|
|
139
|
+
|
|
140
|
+
if splice_client is None or not getattr(splice_client, "connected", False):
|
|
141
|
+
warnings.append(
|
|
142
|
+
"Splice not connected. Will use browser/filesystem fallback."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Get current session info for context
|
|
146
|
+
session_context: dict = {}
|
|
147
|
+
try:
|
|
148
|
+
ableton = ctx.lifespan_context.get("ableton")
|
|
149
|
+
if ableton:
|
|
150
|
+
info = ableton.send_command("get_session_info", {})
|
|
151
|
+
session_context["tempo"] = info.get("tempo", 120)
|
|
152
|
+
session_context["track_count"] = info.get("track_count", 0)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# Augment
|
|
157
|
+
result = _engine.augment(
|
|
158
|
+
request=request,
|
|
159
|
+
max_credits=max_credits,
|
|
160
|
+
max_layers=max_layers,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Override tempo from session if available
|
|
164
|
+
if session_context.get("tempo"):
|
|
165
|
+
result.intent.tempo = int(session_context["tempo"])
|
|
166
|
+
|
|
167
|
+
result.warnings.extend(warnings)
|
|
168
|
+
|
|
169
|
+
output = result.to_dict()
|
|
170
|
+
output["request"] = request
|
|
171
|
+
|
|
172
|
+
if session_context:
|
|
173
|
+
output["session_context"] = session_context
|
|
174
|
+
if credits_remaining is not None:
|
|
175
|
+
output["credits_remaining"] = credits_remaining
|
|
176
|
+
output["credits_budget"] = max_credits
|
|
177
|
+
|
|
178
|
+
return output
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mcp.tool()
|
|
182
|
+
async def get_composition_plan(
|
|
183
|
+
ctx: Context,
|
|
184
|
+
prompt: str,
|
|
185
|
+
) -> dict:
|
|
186
|
+
"""Preview what compose would do without executing or spending credits.
|
|
187
|
+
|
|
188
|
+
Returns the full layer plan with search queries, technique selections,
|
|
189
|
+
processing chains, and arrangement sections. Use to review before
|
|
190
|
+
committing to a full composition.
|
|
191
|
+
|
|
192
|
+
prompt: "dark minimal techno 128bpm with industrial textures"
|
|
193
|
+
"""
|
|
194
|
+
intent = parse_prompt(prompt)
|
|
195
|
+
plan = _engine.get_plan(intent)
|
|
196
|
+
plan["prompt"] = prompt
|
|
197
|
+
plan["note"] = (
|
|
198
|
+
"This is a dry run. No samples searched, downloaded, or loaded. "
|
|
199
|
+
"Use compose() to execute this plan."
|
|
200
|
+
)
|
|
201
|
+
return plan
|
package/mcp_server/connection.py
CHANGED
|
@@ -14,6 +14,11 @@ from typing import Optional
|
|
|
14
14
|
|
|
15
15
|
CONNECT_TIMEOUT = 5
|
|
16
16
|
RECV_TIMEOUT = 20
|
|
17
|
+
SINGLE_CLIENT_RETRY_DELAY = 0.25
|
|
18
|
+
COMMAND_RECV_TIMEOUTS = {
|
|
19
|
+
# Server-side slow write window is 35s; give the client a small buffer.
|
|
20
|
+
"freeze_track": 40,
|
|
21
|
+
}
|
|
17
22
|
|
|
18
23
|
|
|
19
24
|
class AbletonConnectionError(Exception):
|
|
@@ -47,6 +52,19 @@ def _friendly_error(code: str, message: str, command_type: str) -> str:
|
|
|
47
52
|
return " ".join(parts)
|
|
48
53
|
|
|
49
54
|
|
|
55
|
+
def _is_single_client_state_error(response: dict) -> bool:
|
|
56
|
+
"""Return True when the server rejected a fresh connection due to single-client guard."""
|
|
57
|
+
if response.get("ok") is not False:
|
|
58
|
+
return False
|
|
59
|
+
err = response.get("error", {})
|
|
60
|
+
if not isinstance(err, dict):
|
|
61
|
+
return False
|
|
62
|
+
return (
|
|
63
|
+
err.get("code") == "STATE_ERROR"
|
|
64
|
+
and "Another client is already connected" in str(err.get("message", ""))
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
50
68
|
def _identify_other_tcp_client(host: str, port: int) -> str | None:
|
|
51
69
|
"""Return a short description of another established client on the Live port."""
|
|
52
70
|
try:
|
|
@@ -134,9 +152,7 @@ class AbletonConnection:
|
|
|
134
152
|
def ping(self) -> bool:
|
|
135
153
|
"""Send a ping and return True if a pong is received."""
|
|
136
154
|
try:
|
|
137
|
-
|
|
138
|
-
resp = self._send_raw({"type": "ping"})
|
|
139
|
-
return resp.get("result", {}).get("pong") is True
|
|
155
|
+
return self.send_command("ping").get("pong") is True
|
|
140
156
|
except Exception:
|
|
141
157
|
return False
|
|
142
158
|
|
|
@@ -151,7 +167,8 @@ class AbletonConnection:
|
|
|
151
167
|
"""
|
|
152
168
|
with self._lock:
|
|
153
169
|
# Ensure we have a connection
|
|
154
|
-
|
|
170
|
+
fresh_connect = not self.is_connected()
|
|
171
|
+
if fresh_connect:
|
|
155
172
|
self.connect()
|
|
156
173
|
|
|
157
174
|
command: dict = {"type": command_type}
|
|
@@ -159,7 +176,10 @@ class AbletonConnection:
|
|
|
159
176
|
command["params"] = params
|
|
160
177
|
|
|
161
178
|
try:
|
|
162
|
-
response = self._send_raw(
|
|
179
|
+
response = self._send_raw(
|
|
180
|
+
command,
|
|
181
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
182
|
+
)
|
|
163
183
|
except AbletonConnectionError as exc:
|
|
164
184
|
# If the send phase succeeded (data left this process),
|
|
165
185
|
# Ableton may have already applied the command. Never
|
|
@@ -172,12 +192,30 @@ class AbletonConnection:
|
|
|
172
192
|
# Send itself failed — safe to retry with a fresh connection
|
|
173
193
|
self.disconnect()
|
|
174
194
|
self.connect()
|
|
175
|
-
response = self._send_raw(
|
|
195
|
+
response = self._send_raw(
|
|
196
|
+
command,
|
|
197
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
198
|
+
)
|
|
176
199
|
except OSError:
|
|
177
200
|
# Socket error before send — safe to retry
|
|
178
201
|
self.disconnect()
|
|
179
202
|
self.connect()
|
|
180
|
-
response = self._send_raw(
|
|
203
|
+
response = self._send_raw(
|
|
204
|
+
command,
|
|
205
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# The single-client guard can briefly reject an immediate reconnect
|
|
209
|
+
# after this process closes a previous socket. Retry once after a
|
|
210
|
+
# short delay when the command was rejected before execution.
|
|
211
|
+
if fresh_connect and _is_single_client_state_error(response):
|
|
212
|
+
self.disconnect()
|
|
213
|
+
time.sleep(SINGLE_CLIENT_RETRY_DELAY)
|
|
214
|
+
self.connect()
|
|
215
|
+
response = self._send_raw(
|
|
216
|
+
command,
|
|
217
|
+
recv_timeout=COMMAND_RECV_TIMEOUTS.get(command_type, RECV_TIMEOUT),
|
|
218
|
+
)
|
|
181
219
|
|
|
182
220
|
# Log and error handling outside the lock (no socket access needed)
|
|
183
221
|
log_entry = {
|
|
@@ -214,7 +252,7 @@ class AbletonConnection:
|
|
|
214
252
|
# Low-level transport
|
|
215
253
|
# ------------------------------------------------------------------
|
|
216
254
|
|
|
217
|
-
def _send_raw(self, command: dict) -> dict:
|
|
255
|
+
def _send_raw(self, command: dict, recv_timeout: int = RECV_TIMEOUT) -> dict:
|
|
218
256
|
"""Send a JSON command (with request_id) and read the response."""
|
|
219
257
|
if self._socket is None:
|
|
220
258
|
raise AbletonConnectionError("Not connected to Ableton Live")
|
|
@@ -222,6 +260,7 @@ class AbletonConnection:
|
|
|
222
260
|
# Don't mutate the caller's dict
|
|
223
261
|
envelope = {**command, "id": str(uuid.uuid4())[:8]}
|
|
224
262
|
payload = json.dumps(envelope) + "\n"
|
|
263
|
+
self._socket.settimeout(recv_timeout)
|
|
225
264
|
|
|
226
265
|
try:
|
|
227
266
|
self._socket.sendall(payload.encode("utf-8"))
|
|
@@ -283,3 +322,9 @@ class AbletonConnection:
|
|
|
283
322
|
raise AbletonConnectionError(
|
|
284
323
|
f"Invalid JSON from Ableton: {line[:200]}"
|
|
285
324
|
) from exc
|
|
325
|
+
finally:
|
|
326
|
+
if self._socket is not None:
|
|
327
|
+
try:
|
|
328
|
+
self._socket.settimeout(RECV_TIMEOUT)
|
|
329
|
+
except OSError:
|
|
330
|
+
pass
|