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.
Files changed (191) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/AGENTS.md +3 -3
  3. package/CHANGELOG.md +119 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +144 -13
  6. package/bin/livepilot.js +87 -0
  7. package/installer/codex.js +147 -0
  8. package/livepilot/.Codex-plugin/plugin.json +2 -2
  9. package/livepilot/.claude-plugin/plugin.json +2 -2
  10. package/livepilot/skills/livepilot-core/SKILL.md +21 -4
  11. package/livepilot/skills/livepilot-core/references/device-knowledge/00-index.md +34 -0
  12. package/livepilot/skills/livepilot-core/references/device-knowledge/automation-as-music.md +204 -0
  13. package/livepilot/skills/livepilot-core/references/device-knowledge/chains-genre.md +173 -0
  14. package/livepilot/skills/livepilot-core/references/device-knowledge/creative-thinking.md +211 -0
  15. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-distortion.md +188 -0
  16. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-space.md +162 -0
  17. package/livepilot/skills/livepilot-core/references/device-knowledge/effects-spectral.md +229 -0
  18. package/livepilot/skills/livepilot-core/references/device-knowledge/instruments-synths.md +243 -0
  19. package/livepilot/skills/livepilot-core/references/overview.md +13 -9
  20. package/livepilot/skills/livepilot-core/references/sample-manipulation.md +724 -0
  21. package/livepilot/skills/livepilot-core/references/sound-design-deep.md +140 -0
  22. package/livepilot/skills/livepilot-devices/SKILL.md +16 -2
  23. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  24. package/livepilot/skills/livepilot-release/SKILL.md +19 -5
  25. package/livepilot/skills/livepilot-sample-engine/SKILL.md +104 -0
  26. package/livepilot/skills/livepilot-sample-engine/references/sample-critics.md +87 -0
  27. package/livepilot/skills/livepilot-sample-engine/references/sample-philosophy.md +51 -0
  28. package/livepilot/skills/livepilot-sample-engine/references/sample-techniques.md +131 -0
  29. package/livepilot/skills/livepilot-sound-design-engine/SKILL.md +45 -0
  30. package/livepilot/skills/livepilot-wonder/SKILL.md +15 -0
  31. package/livepilot.mcpb +0 -0
  32. package/m4l_device/livepilot_bridge.js +1 -1
  33. package/manifest.json +2 -2
  34. package/mcp_server/__init__.py +1 -1
  35. package/mcp_server/atlas/__init__.py +357 -0
  36. package/mcp_server/atlas/device_atlas.json +44067 -0
  37. package/mcp_server/atlas/enrichments/__init__.py +111 -0
  38. package/mcp_server/atlas/enrichments/audio_effects/auto_filter.yaml +162 -0
  39. package/mcp_server/atlas/enrichments/audio_effects/beat_repeat.yaml +183 -0
  40. package/mcp_server/atlas/enrichments/audio_effects/channel_eq.yaml +126 -0
  41. package/mcp_server/atlas/enrichments/audio_effects/chorus_ensemble.yaml +149 -0
  42. package/mcp_server/atlas/enrichments/audio_effects/color_limiter.yaml +109 -0
  43. package/mcp_server/atlas/enrichments/audio_effects/compressor.yaml +159 -0
  44. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb.yaml +143 -0
  45. package/mcp_server/atlas/enrichments/audio_effects/convolution_reverb_pro.yaml +178 -0
  46. package/mcp_server/atlas/enrichments/audio_effects/delay.yaml +151 -0
  47. package/mcp_server/atlas/enrichments/audio_effects/drum_buss.yaml +142 -0
  48. package/mcp_server/atlas/enrichments/audio_effects/dynamic_tube.yaml +147 -0
  49. package/mcp_server/atlas/enrichments/audio_effects/echo.yaml +167 -0
  50. package/mcp_server/atlas/enrichments/audio_effects/eq_eight.yaml +148 -0
  51. package/mcp_server/atlas/enrichments/audio_effects/eq_three.yaml +121 -0
  52. package/mcp_server/atlas/enrichments/audio_effects/erosion.yaml +103 -0
  53. package/mcp_server/atlas/enrichments/audio_effects/filter_delay.yaml +173 -0
  54. package/mcp_server/atlas/enrichments/audio_effects/gate.yaml +130 -0
  55. package/mcp_server/atlas/enrichments/audio_effects/gated_delay.yaml +133 -0
  56. package/mcp_server/atlas/enrichments/audio_effects/glue_compressor.yaml +142 -0
  57. package/mcp_server/atlas/enrichments/audio_effects/grain_delay.yaml +141 -0
  58. package/mcp_server/atlas/enrichments/audio_effects/hybrid_reverb.yaml +160 -0
  59. package/mcp_server/atlas/enrichments/audio_effects/limiter.yaml +97 -0
  60. package/mcp_server/atlas/enrichments/audio_effects/multiband_dynamics.yaml +174 -0
  61. package/mcp_server/atlas/enrichments/audio_effects/overdrive.yaml +119 -0
  62. package/mcp_server/atlas/enrichments/audio_effects/pedal.yaml +145 -0
  63. package/mcp_server/atlas/enrichments/audio_effects/phaser_flanger.yaml +161 -0
  64. package/mcp_server/atlas/enrichments/audio_effects/redux.yaml +114 -0
  65. package/mcp_server/atlas/enrichments/audio_effects/reverb.yaml +190 -0
  66. package/mcp_server/atlas/enrichments/audio_effects/roar.yaml +159 -0
  67. package/mcp_server/atlas/enrichments/audio_effects/saturator.yaml +146 -0
  68. package/mcp_server/atlas/enrichments/audio_effects/shifter.yaml +154 -0
  69. package/mcp_server/atlas/enrichments/audio_effects/spectral_resonator.yaml +141 -0
  70. package/mcp_server/atlas/enrichments/audio_effects/spectral_time.yaml +164 -0
  71. package/mcp_server/atlas/enrichments/audio_effects/vector_delay.yaml +140 -0
  72. package/mcp_server/atlas/enrichments/audio_effects/vinyl_distortion.yaml +141 -0
  73. package/mcp_server/atlas/enrichments/instruments/analog.yaml +222 -0
  74. package/mcp_server/atlas/enrichments/instruments/bass.yaml +202 -0
  75. package/mcp_server/atlas/enrichments/instruments/collision.yaml +150 -0
  76. package/mcp_server/atlas/enrichments/instruments/drift.yaml +167 -0
  77. package/mcp_server/atlas/enrichments/instruments/electric.yaml +137 -0
  78. package/mcp_server/atlas/enrichments/instruments/emit.yaml +163 -0
  79. package/mcp_server/atlas/enrichments/instruments/meld.yaml +164 -0
  80. package/mcp_server/atlas/enrichments/instruments/operator.yaml +197 -0
  81. package/mcp_server/atlas/enrichments/instruments/poli.yaml +192 -0
  82. package/mcp_server/atlas/enrichments/instruments/sampler.yaml +218 -0
  83. package/mcp_server/atlas/enrichments/instruments/simpler.yaml +217 -0
  84. package/mcp_server/atlas/enrichments/instruments/tension.yaml +156 -0
  85. package/mcp_server/atlas/enrichments/instruments/tree_tone.yaml +162 -0
  86. package/mcp_server/atlas/enrichments/instruments/vector_fm.yaml +165 -0
  87. package/mcp_server/atlas/enrichments/instruments/vector_grain.yaml +166 -0
  88. package/mcp_server/atlas/enrichments/instruments/wavetable.yaml +162 -0
  89. package/mcp_server/atlas/enrichments/midi_effects/arpeggiator.yaml +156 -0
  90. package/mcp_server/atlas/enrichments/midi_effects/bouncy_notes.yaml +93 -0
  91. package/mcp_server/atlas/enrichments/midi_effects/chord.yaml +147 -0
  92. package/mcp_server/atlas/enrichments/midi_effects/melodic_steps.yaml +97 -0
  93. package/mcp_server/atlas/enrichments/midi_effects/note_echo.yaml +108 -0
  94. package/mcp_server/atlas/enrichments/midi_effects/note_length.yaml +97 -0
  95. package/mcp_server/atlas/enrichments/midi_effects/pitch.yaml +76 -0
  96. package/mcp_server/atlas/enrichments/midi_effects/random.yaml +117 -0
  97. package/mcp_server/atlas/enrichments/midi_effects/rhythmic_steps.yaml +103 -0
  98. package/mcp_server/atlas/enrichments/midi_effects/scale.yaml +83 -0
  99. package/mcp_server/atlas/enrichments/midi_effects/step_arp.yaml +112 -0
  100. package/mcp_server/atlas/enrichments/midi_effects/velocity.yaml +119 -0
  101. package/mcp_server/atlas/enrichments/utility/amp.yaml +159 -0
  102. package/mcp_server/atlas/enrichments/utility/cabinet.yaml +109 -0
  103. package/mcp_server/atlas/enrichments/utility/corpus.yaml +150 -0
  104. package/mcp_server/atlas/enrichments/utility/resonators.yaml +131 -0
  105. package/mcp_server/atlas/enrichments/utility/spectrum.yaml +63 -0
  106. package/mcp_server/atlas/enrichments/utility/tuner.yaml +51 -0
  107. package/mcp_server/atlas/enrichments/utility/utility.yaml +136 -0
  108. package/mcp_server/atlas/enrichments/utility/vocoder.yaml +160 -0
  109. package/mcp_server/atlas/scanner.py +236 -0
  110. package/mcp_server/atlas/tools.py +224 -0
  111. package/mcp_server/composer/__init__.py +1 -0
  112. package/mcp_server/composer/engine.py +452 -0
  113. package/mcp_server/composer/layer_planner.py +427 -0
  114. package/mcp_server/composer/prompt_parser.py +329 -0
  115. package/mcp_server/composer/tools.py +201 -0
  116. package/mcp_server/connection.py +53 -8
  117. package/mcp_server/corpus/__init__.py +377 -0
  118. package/mcp_server/device_forge/__init__.py +1 -0
  119. package/mcp_server/device_forge/builder.py +377 -0
  120. package/mcp_server/device_forge/models.py +142 -0
  121. package/mcp_server/device_forge/templates.py +483 -0
  122. package/mcp_server/device_forge/tools.py +162 -0
  123. package/mcp_server/hook_hunter/analyzer.py +23 -0
  124. package/mcp_server/hook_hunter/models.py +1 -0
  125. package/mcp_server/hook_hunter/tools.py +4 -2
  126. package/mcp_server/m4l_bridge.py +1 -0
  127. package/mcp_server/memory/taste_graph.py +68 -1
  128. package/mcp_server/memory/tools.py +15 -4
  129. package/mcp_server/musical_intelligence/detectors.py +14 -1
  130. package/mcp_server/musical_intelligence/tools.py +11 -8
  131. package/mcp_server/persistence/__init__.py +1 -0
  132. package/mcp_server/persistence/base_store.py +82 -0
  133. package/mcp_server/persistence/project_store.py +106 -0
  134. package/mcp_server/persistence/taste_store.py +122 -0
  135. package/mcp_server/preview_studio/models.py +1 -0
  136. package/mcp_server/preview_studio/tools.py +56 -13
  137. package/mcp_server/runtime/capability.py +66 -0
  138. package/mcp_server/runtime/capability_probe.py +137 -0
  139. package/mcp_server/runtime/execution_router.py +143 -0
  140. package/mcp_server/runtime/live_version.py +102 -0
  141. package/mcp_server/runtime/remote_commands.py +87 -0
  142. package/mcp_server/runtime/tools.py +18 -4
  143. package/mcp_server/sample_engine/__init__.py +1 -0
  144. package/mcp_server/sample_engine/analyzer.py +216 -0
  145. package/mcp_server/sample_engine/critics.py +390 -0
  146. package/mcp_server/sample_engine/models.py +193 -0
  147. package/mcp_server/sample_engine/moves.py +127 -0
  148. package/mcp_server/sample_engine/planner.py +186 -0
  149. package/mcp_server/sample_engine/sources.py +540 -0
  150. package/mcp_server/sample_engine/techniques.py +908 -0
  151. package/mcp_server/sample_engine/tools.py +442 -0
  152. package/mcp_server/semantic_moves/__init__.py +3 -0
  153. package/mcp_server/semantic_moves/device_creation_moves.py +237 -0
  154. package/mcp_server/semantic_moves/mix_moves.py +41 -41
  155. package/mcp_server/semantic_moves/performance_moves.py +13 -13
  156. package/mcp_server/semantic_moves/sample_compilers.py +372 -0
  157. package/mcp_server/semantic_moves/sound_design_moves.py +15 -15
  158. package/mcp_server/semantic_moves/tools.py +18 -17
  159. package/mcp_server/semantic_moves/transition_moves.py +16 -16
  160. package/mcp_server/server.py +51 -0
  161. package/mcp_server/services/__init__.py +1 -0
  162. package/mcp_server/services/motif_service.py +67 -0
  163. package/mcp_server/session_continuity/tracker.py +29 -1
  164. package/mcp_server/song_brain/builder.py +28 -1
  165. package/mcp_server/song_brain/models.py +4 -0
  166. package/mcp_server/song_brain/tools.py +20 -2
  167. package/mcp_server/sound_design/critics.py +89 -1
  168. package/mcp_server/splice_client/__init__.py +1 -0
  169. package/mcp_server/splice_client/client.py +347 -0
  170. package/mcp_server/splice_client/models.py +96 -0
  171. package/mcp_server/splice_client/protos/__init__.py +1 -0
  172. package/mcp_server/splice_client/protos/app_pb2.py +319 -0
  173. package/mcp_server/splice_client/protos/app_pb2.pyi +1153 -0
  174. package/mcp_server/splice_client/protos/app_pb2_grpc.py +1946 -0
  175. package/mcp_server/tools/arrangement.py +69 -0
  176. package/mcp_server/tools/automation.py +15 -2
  177. package/mcp_server/tools/devices.py +117 -6
  178. package/mcp_server/tools/notes.py +37 -4
  179. package/mcp_server/wonder_mode/diagnosis.py +5 -0
  180. package/mcp_server/wonder_mode/engine.py +85 -1
  181. package/mcp_server/wonder_mode/tools.py +6 -1
  182. package/package.json +12 -2
  183. package/remote_script/LivePilot/__init__.py +8 -1
  184. package/remote_script/LivePilot/arrangement.py +114 -0
  185. package/remote_script/LivePilot/browser.py +56 -1
  186. package/remote_script/LivePilot/devices.py +236 -6
  187. package/remote_script/LivePilot/mixing.py +8 -3
  188. package/remote_script/LivePilot/server.py +5 -1
  189. package/remote_script/LivePilot/transport.py +3 -0
  190. package/remote_script/LivePilot/version_detect.py +78 -0
  191. package/scripts/sync_metadata.py +132 -0
@@ -0,0 +1,377 @@
1
+ """Build .amxd binary files from DeviceSpec — pure Python, no dependencies.
2
+
3
+ Binary format (reverse-engineered from LivePilot_Analyzer.amxd):
4
+ Offset 0x00: "ampf" + uint32_LE(4) + device_marker (4 bytes)
5
+ Offset 0x0C: "meta" + uint32_LE(4) + uint32_LE(meta_value)
6
+ Offset 0x18: "ptch" + uint32_LE(content_size)
7
+ Offset 0x20: "mx@c" + uint32_BE(16) + uint32_BE(0) + uint32_BE(json_size)
8
+ Offset 0x30: JSON patcher (UTF-8)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import struct
15
+ from pathlib import Path
16
+
17
+ from .models import DeviceSpec, DeviceType, GenExprParam
18
+
19
+ ABLETON_USER_LIBRARY = Path.home() / "Music" / "Ableton" / "User Library"
20
+
21
+ _SUBDIR_MAP = {
22
+ DeviceType.AUDIO_EFFECT: "Presets/Audio Effects/Max Audio Effect",
23
+ DeviceType.MIDI_EFFECT: "Presets/MIDI Effects/Max MIDI Effect",
24
+ DeviceType.INSTRUMENT: "Presets/Instruments/Max Instrument",
25
+ DeviceType.MIDI_GENERATOR: "Presets/MIDI Effects/Max MIDI Effect",
26
+ DeviceType.MIDI_TRANSFORMATION: "Presets/MIDI Effects/Max MIDI Effect",
27
+ }
28
+
29
+
30
+ # ── JSON Patcher Generation ─────────────────────────────────────────
31
+
32
+
33
+ def _make_box(obj_id: str, maxclass: str, text: str,
34
+ numinlets: int, numoutlets: int,
35
+ outlettype: list[str], rect: list[float],
36
+ **extra) -> dict:
37
+ """Create a single Max box dict."""
38
+ box: dict = {
39
+ "id": obj_id,
40
+ "maxclass": maxclass,
41
+ "text": text,
42
+ "numinlets": numinlets,
43
+ "numoutlets": numoutlets,
44
+ "outlettype": outlettype,
45
+ "patching_rect": rect,
46
+ }
47
+ box.update(extra)
48
+ return {"box": box}
49
+
50
+
51
+ def _make_line(src_id: str, src_outlet: int,
52
+ dst_id: str, dst_inlet: int) -> dict:
53
+ return {"patchline": {"source": [src_id, src_outlet],
54
+ "destination": [dst_id, dst_inlet]}}
55
+
56
+
57
+ def _ensure_safety_clip(code: str) -> str:
58
+ """Append safety clipping to gen~ code to prevent speaker damage."""
59
+ safe = code.rstrip()
60
+ if "clip(" in safe.lower():
61
+ return safe
62
+
63
+ # Find output assignments and wrap them
64
+ lines = safe.split("\n")
65
+ new_lines = []
66
+ for line in lines:
67
+ stripped = line.strip()
68
+ if stripped.startswith("out") and "=" in stripped:
69
+ var = stripped.split("=")[0].strip()
70
+ new_lines.append(line)
71
+ new_lines.append(f"{var} = clip({var}, -1, 1);")
72
+ else:
73
+ new_lines.append(line)
74
+ return "\n".join(new_lines)
75
+
76
+
77
+ def _build_gen_patcher(spec: DeviceSpec) -> dict:
78
+ """Build the gen~ sub-patcher with codebox containing user's GenExpr code."""
79
+ safe_code = _ensure_safety_clip(spec.gen_code)
80
+
81
+ boxes = []
82
+ lines = []
83
+
84
+ # Codebox — maxclass MUST be "codebox" (not "newobj" with text "codebox")
85
+ # Canonical format verified against 18 factory codebox objects
86
+ boxes.append({
87
+ "box": {
88
+ "id": "obj-codebox",
89
+ "maxclass": "codebox",
90
+ "numinlets": 1,
91
+ "numoutlets": 1,
92
+ "outlettype": [""],
93
+ "patching_rect": [50.0, 100.0, 400.0, 200.0],
94
+ "fontface": 0,
95
+ "fontname": "<Monospaced>",
96
+ "fontsize": 12.0,
97
+ "code": safe_code,
98
+ }
99
+ })
100
+
101
+ # in 1 (audio or data input)
102
+ boxes.append(_make_box("obj-in1", "newobj", "in 1", 0, 1, [""], [50.0, 30.0, 30.0, 22.0]))
103
+ lines.append(_make_line("obj-in1", 0, "obj-codebox", 0))
104
+
105
+ # out 1
106
+ boxes.append(_make_box("obj-out1", "newobj", "out 1", 1, 0, [], [50.0, 350.0, 35.0, 22.0]))
107
+ lines.append(_make_line("obj-codebox", 0, "obj-out1", 0))
108
+
109
+ # Param objects for each parameter
110
+ for i, param in enumerate(spec.params):
111
+ param_id = f"obj-param{i}"
112
+ boxes.append(_make_box(
113
+ param_id, "newobj",
114
+ f"param {param.name} @default {param.default} @min {param.min_val} @max {param.max_val}",
115
+ 0, 1, [""], [200.0 + i * 120, 30.0, 150.0, 22.0],
116
+ ))
117
+
118
+ return {
119
+ "fileversion": 1,
120
+ "appversion": {"major": 9, "minor": 0, "revision": 5,
121
+ "architecture": "x64", "modernui": 1},
122
+ "classnamespace": "dsp.gen",
123
+ "rect": [100.0, 100.0, 600.0, 450.0],
124
+ "boxes": boxes,
125
+ "lines": lines,
126
+ }
127
+
128
+
129
+ def _patcher_boilerplate(spec: DeviceSpec) -> dict:
130
+ """Common patcher-level fields for all device types."""
131
+ return {
132
+ "fileversion": 1,
133
+ "appversion": {"major": 9, "minor": 0, "revision": 5,
134
+ "architecture": "x64", "modernui": 1},
135
+ "classnamespace": "box",
136
+ "rect": [100.0, 100.0, 800.0, 600.0],
137
+ "openinpresentation": 1,
138
+ "default_fontsize": 12.0,
139
+ "default_fontface": 0,
140
+ "default_fontname": "Arial",
141
+ "gridonopen": 1,
142
+ "gridsize": [15.0, 15.0],
143
+ "gridsnaponopen": 1,
144
+ "objectsnaponopen": 1,
145
+ "statusbarvisible": 2,
146
+ "toolbarvisible": 1,
147
+ "lefttoolbarpinned": 0,
148
+ "toptoolbarpinned": 0,
149
+ "righttoolbarpinned": 0,
150
+ "bottomtoolbarpinned": 0,
151
+ "toolbars_unpinned_last_save": 0,
152
+ "tallnewobj": 0,
153
+ "boxanimatetime": 200,
154
+ "enablehscroll": 1,
155
+ "enablevscroll": 1,
156
+ "devicewidth": float(spec.width),
157
+ "description": spec.description,
158
+ "digest": spec.description,
159
+ "tags": spec.tags,
160
+ "style": "",
161
+ "subpatcher_template": "",
162
+ "assistshowspatchername": 0,
163
+ }
164
+
165
+
166
+ def _build_audio_effect_patcher(spec: DeviceSpec) -> dict:
167
+ """Build patcher for an audio effect: plugin~ -> gen~ -> plugout~."""
168
+ boxes = []
169
+ lines = []
170
+ _counter = [0]
171
+
172
+ def nid():
173
+ _counter[0] += 1
174
+ return f"obj-{_counter[0]}"
175
+
176
+ # Background panel — background=1 sends it behind all other UI elements
177
+ pid = nid()
178
+ boxes.append({
179
+ "box": {
180
+ "id": pid, "maxclass": "panel", "numinlets": 1, "numoutlets": 0,
181
+ "patching_rect": [0.0, 0.0, float(spec.width), float(spec.height)],
182
+ "presentation": 1,
183
+ "presentation_rect": [0.0, 0.0, float(spec.width), float(spec.height)],
184
+ "bgcolor": [0.12, 0.12, 0.12, 1.0],
185
+ "background": 1,
186
+ }
187
+ })
188
+
189
+ # plugin~ — numinlets=2 so Live can feed audio into the device
190
+ plugin_id = nid()
191
+ boxes.append(_make_box(plugin_id, "newobj", "plugin~", 2, 2,
192
+ ["signal", "signal"], [50.0, 30.0, 65.0, 22.0]))
193
+
194
+ # plugout~
195
+ plugout_id = nid()
196
+ boxes.append(_make_box(plugout_id, "newobj", "plugout~", 2, 2,
197
+ ["signal", "signal"], [50.0, 400.0, 70.0, 22.0]))
198
+
199
+ # gen~ with embedded patcher
200
+ gen_id = nid()
201
+ boxes.append({
202
+ "box": {
203
+ "id": gen_id, "maxclass": "newobj", "text": "gen~",
204
+ "numinlets": 1, "numoutlets": 1, "outlettype": ["signal"],
205
+ "patching_rect": [50.0, 200.0, 300.0, 22.0],
206
+ "patcher": _build_gen_patcher(spec),
207
+ }
208
+ })
209
+
210
+ # Signal path: L channel through gen~, R channel direct passthrough
211
+ # plugin~ L -> gen~ -> plugout~ L
212
+ lines.append(_make_line(plugin_id, 0, gen_id, 0))
213
+ lines.append(_make_line(gen_id, 0, plugout_id, 0))
214
+ # plugin~ R -> plugout~ R (direct passthrough)
215
+ lines.append(_make_line(plugin_id, 1, plugout_id, 1))
216
+
217
+ # live.dial for each parameter
218
+ for i, param in enumerate(spec.params):
219
+ did = nid()
220
+ x = 10.0 + i * 54.0
221
+ boxes.append(param.to_live_dial_json(did, [x, 10.0, 44.0, 48.0]))
222
+
223
+ # Title comment
224
+ tid = nid()
225
+ boxes.append({
226
+ "box": {
227
+ "id": tid, "maxclass": "comment", "text": spec.name,
228
+ "numinlets": 1, "numoutlets": 0,
229
+ "patching_rect": [50.0, 440.0, 200.0, 20.0],
230
+ "presentation": 1,
231
+ "presentation_rect": [10.0, float(spec.height - 20), 200.0, 18.0],
232
+ "textcolor": [0.7, 0.7, 0.7, 1.0], "fontsize": 10.0,
233
+ }
234
+ })
235
+
236
+ p = _patcher_boilerplate(spec)
237
+ p["boxes"] = boxes
238
+ p["lines"] = lines
239
+ return {"patcher": p}
240
+
241
+
242
+ def _build_midi_effect_patcher(spec: DeviceSpec) -> dict:
243
+ boxes = [
244
+ _make_box("obj-1", "newobj", "midiin", 1, 1, ["int"], [50.0, 30.0, 50.0, 22.0]),
245
+ _make_box("obj-2", "newobj", "midiout", 1, 0, [], [50.0, 300.0, 55.0, 22.0]),
246
+ ]
247
+ lines = [_make_line("obj-1", 0, "obj-2", 0)]
248
+ p = _patcher_boilerplate(spec)
249
+ p["boxes"] = boxes
250
+ p["lines"] = lines
251
+ return {"patcher": p}
252
+
253
+
254
+ def _build_instrument_patcher(spec: DeviceSpec) -> dict:
255
+ boxes = [
256
+ _make_box("obj-mi", "newobj", "midiin", 1, 1, ["int"], [50.0, 30.0, 50.0, 22.0]),
257
+ _make_box("obj-mp", "newobj", "midiparse", 1, 8,
258
+ ["", "", "", "", "", "", "", ""], [50.0, 70.0, 100.0, 22.0]),
259
+ _make_box("obj-mtof", "newobj", "mtof", 1, 1, [""], [50.0, 110.0, 40.0, 22.0]),
260
+ ]
261
+
262
+ gen_patcher = _build_gen_patcher(spec)
263
+ boxes.append({
264
+ "box": {
265
+ "id": "obj-gen", "maxclass": "newobj", "text": "gen~",
266
+ "numinlets": 1, "numoutlets": 1, "outlettype": ["signal"],
267
+ "patching_rect": [50.0, 200.0, 300.0, 22.0],
268
+ "patcher": gen_patcher,
269
+ }
270
+ })
271
+ boxes.append(_make_box("obj-po", "newobj", "plugout~", 2, 2,
272
+ ["signal", "signal"], [50.0, 300.0, 70.0, 22.0]))
273
+
274
+ lines = [
275
+ _make_line("obj-mi", 0, "obj-mp", 0),
276
+ _make_line("obj-mp", 0, "obj-mtof", 0),
277
+ _make_line("obj-mtof", 0, "obj-gen", 0),
278
+ _make_line("obj-gen", 0, "obj-po", 0),
279
+ _make_line("obj-gen", 0, "obj-po", 1),
280
+ ]
281
+
282
+ p = _patcher_boilerplate(spec)
283
+ p["boxes"] = boxes
284
+ p["lines"] = lines
285
+ return {"patcher": p}
286
+
287
+
288
+ _PATCHER_BUILDERS = {
289
+ DeviceType.AUDIO_EFFECT: _build_audio_effect_patcher,
290
+ DeviceType.MIDI_EFFECT: _build_midi_effect_patcher,
291
+ DeviceType.INSTRUMENT: _build_instrument_patcher,
292
+ }
293
+
294
+
295
+ def build_patcher_json(spec: DeviceSpec) -> dict:
296
+ """Build the complete .maxpat JSON patcher dict for a device spec."""
297
+ return _PATCHER_BUILDERS[spec.device_type](spec)
298
+
299
+
300
+ def build_amxd_binary(spec: DeviceSpec) -> bytes:
301
+ """Build the complete .amxd binary from a device spec.
302
+
303
+ Unfrozen .amxd format (32-byte header + JSON):
304
+ ampf(4) + uint32_LE(4) + device_marker(4) = 12 bytes
305
+ meta(4) + uint32_LE(4) + uint32_LE(0) = 12 bytes
306
+ ptch(4) + uint32_LE(json_size) = 8 bytes
307
+ JSON patcher (UTF-8)
308
+
309
+ Note: The mx@c wrapper is only used for FROZEN devices with embedded
310
+ dependencies. Unfrozen devices put JSON directly after the ptch header.
311
+ """
312
+ patcher = build_patcher_json(spec)
313
+ json_bytes = json.dumps(patcher, indent="\t", separators=(",", " : "),
314
+ ensure_ascii=False).encode("utf-8")
315
+
316
+ dt = spec.device_type
317
+
318
+ # ampf header (12 bytes)
319
+ header = b"ampf"
320
+ header += struct.pack("<I", 4)
321
+ header += dt.ampf_marker
322
+
323
+ # meta chunk (12 bytes) — meta_value=0 for unfrozen devices
324
+ header += b"meta"
325
+ header += struct.pack("<I", 4)
326
+ header += struct.pack("<I", 0)
327
+
328
+ # ptch chunk (8 bytes) — size = JSON byte length
329
+ header += b"ptch"
330
+ header += struct.pack("<I", len(json_bytes))
331
+
332
+ return header + json_bytes
333
+
334
+
335
+ def parse_amxd_header(data: bytes) -> dict:
336
+ """Parse an .amxd binary header. Returns dict with metadata."""
337
+ if len(data) < 32 or data[:4] != b"ampf":
338
+ raise ValueError("Not a valid .amxd file")
339
+
340
+ marker = data[8:12]
341
+ type_map = {
342
+ b"aaaa": "audio_effect", b"mmmm": "midi_effect", b"iiii": "instrument",
343
+ b"nagg": "midi_generator", b"natt": "midi_transformation",
344
+ }
345
+ ptch_size = struct.unpack("<I", data[28:32])[0]
346
+
347
+ # Detect frozen vs unfrozen: frozen has mx@c at offset 32
348
+ frozen = data[32:36] == b"mx@c"
349
+ json_offset = 48 if frozen else 32
350
+
351
+ return {
352
+ "device_type": type_map.get(marker, "unknown"),
353
+ "meta_value": struct.unpack("<I", data[20:24])[0],
354
+ "ptch_size": ptch_size,
355
+ "json_offset": json_offset,
356
+ "frozen": frozen,
357
+ }
358
+
359
+
360
+ def build_device(spec: DeviceSpec, output_dir: str | Path | None = None) -> Path:
361
+ """Build an .amxd file and write it to disk.
362
+
363
+ If output_dir is None, writes to the Ableton User Library.
364
+ Returns the path to the created file.
365
+ """
366
+ data = build_amxd_binary(spec)
367
+
368
+ if output_dir is None:
369
+ subdir = _SUBDIR_MAP[spec.device_type]
370
+ output_dir = ABLETON_USER_LIBRARY / subdir
371
+
372
+ output_dir = Path(output_dir)
373
+ output_dir.mkdir(parents=True, exist_ok=True)
374
+
375
+ path = output_dir / spec.safe_filename
376
+ path.write_bytes(data)
377
+ return path
@@ -0,0 +1,142 @@
1
+ """Device Forge data models — specs for generated M4L devices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+
9
+
10
+ class DeviceType(Enum):
11
+ """M4L device types with binary format metadata."""
12
+
13
+ AUDIO_EFFECT = ("aaaa", 7, "Max Audio Effect", ("plugin~", "plugout~"))
14
+ MIDI_EFFECT = ("mmmm", 1, "Max MIDI Effect", ("midiin", "midiout"))
15
+ INSTRUMENT = ("iiii", 2, "Max Instrument", ("midiin", "plugout~"))
16
+ MIDI_GENERATOR = ("nagg", 3, "Max MIDI Generator", ("midiout",))
17
+ MIDI_TRANSFORMATION = ("natt", 4, "Max MIDI Transformation", ("midiin", "midiout"))
18
+
19
+ def __init__(self, ampf: str, meta: int, title: str, io: tuple):
20
+ self._ampf = ampf.encode("ascii")
21
+ self._meta = meta
22
+ self._title = title
23
+ self._io = io
24
+
25
+ @property
26
+ def ampf_marker(self) -> bytes:
27
+ return self._ampf
28
+
29
+ @property
30
+ def meta_value(self) -> int:
31
+ return self._meta
32
+
33
+ @property
34
+ def title(self) -> str:
35
+ return self._title
36
+
37
+ @property
38
+ def required_io(self) -> tuple:
39
+ return self._io
40
+
41
+
42
+ # Parameter unit styles matching Live's enum
43
+ UNIT_STYLE_INT = 0
44
+ UNIT_STYLE_FLOAT = 1
45
+ UNIT_STYLE_TIME = 2
46
+ UNIT_STYLE_HERTZ = 3
47
+ UNIT_STYLE_DB = 4
48
+ UNIT_STYLE_PERCENT = 5
49
+ UNIT_STYLE_PAN = 6
50
+ UNIT_STYLE_SEMITONES = 7
51
+
52
+
53
+ @dataclass
54
+ class GenExprParam:
55
+ """A gen~ parameter exposed to Ableton as a live.dial."""
56
+
57
+ name: str
58
+ default: float = 0.5
59
+ min_val: float = 0.0
60
+ max_val: float = 1.0
61
+ unit_style: int = UNIT_STYLE_FLOAT
62
+ exponent: float = 1.0 # 1.0 = linear
63
+
64
+ def to_genexpr(self) -> str:
65
+ """Generate the Param declaration for gen~ codebox."""
66
+ return f"Param {self.name}({self.default});"
67
+
68
+ def to_live_dial_json(self, obj_id: str, rect: list[float]) -> dict:
69
+ """Generate the JSON for a live.dial box wired to this parameter."""
70
+ return {
71
+ "box": {
72
+ "id": obj_id,
73
+ "maxclass": "live.dial",
74
+ "numinlets": 1,
75
+ "numoutlets": 2,
76
+ "outlettype": ["", "float"],
77
+ "parameter_enable": 1,
78
+ "patching_rect": rect,
79
+ "presentation": 1,
80
+ "presentation_rect": rect,
81
+ "saved_attribute_attributes": {
82
+ "valueof": {
83
+ "parameter_longname": self.name,
84
+ "parameter_shortname": self.name[:7],
85
+ "parameter_type": 0,
86
+ "parameter_mmin": self.min_val,
87
+ "parameter_mmax": self.max_val,
88
+ "parameter_unitstyle": self.unit_style,
89
+ "parameter_initial_enable": 1,
90
+ "parameter_initial": [self.default],
91
+ "parameter_exponent": self.exponent,
92
+ }
93
+ },
94
+ "varname": self.name,
95
+ }
96
+ }
97
+
98
+
99
+ @dataclass
100
+ class GenExprTemplate:
101
+ """A reusable gen~ DSP building block."""
102
+
103
+ template_id: str
104
+ name: str
105
+ description: str
106
+ category: str # chaos, delay, distortion, filter, modulation, synthesis, texture, utility
107
+ code: str # GenExpr source code
108
+ params: list[GenExprParam] = field(default_factory=list)
109
+ num_inputs: int = 1
110
+ num_outputs: int = 1
111
+
112
+ def to_dict(self) -> dict:
113
+ """Summary dict — does NOT expose raw code."""
114
+ return {
115
+ "template_id": self.template_id,
116
+ "name": self.name,
117
+ "description": self.description,
118
+ "category": self.category,
119
+ "params": [p.name for p in self.params],
120
+ "num_inputs": self.num_inputs,
121
+ "num_outputs": self.num_outputs,
122
+ }
123
+
124
+
125
+ @dataclass
126
+ class DeviceSpec:
127
+ """Complete specification for a generated M4L device."""
128
+
129
+ name: str
130
+ device_type: DeviceType
131
+ gen_code: str # GenExpr source for the gen~ codebox
132
+ description: str = ""
133
+ params: list[GenExprParam] = field(default_factory=list)
134
+ width: int = 300
135
+ height: int = 100
136
+ tags: str = "livepilot generated"
137
+
138
+ @property
139
+ def safe_filename(self) -> str:
140
+ """Filesystem-safe .amxd filename."""
141
+ clean = re.sub(r"[^a-zA-Z0-9_ ]", "", self.name)
142
+ return clean.strip().replace(" ", "_") + ".amxd"