bitwig-agent 0.1.1__py3-none-any.whl
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.
- bitwig_agent/__init__.py +2 -0
- bitwig_agent/__main__.py +6 -0
- bitwig_agent/catalog.py +101 -0
- bitwig_agent/cli.py +118 -0
- bitwig_agent/client.py +118 -0
- bitwig_agent/install.py +130 -0
- bitwig_agent/schema.py +372 -0
- bitwig_agent/server.py +60 -0
- bitwig_agent-0.1.1.dist-info/METADATA +98 -0
- bitwig_agent-0.1.1.dist-info/RECORD +13 -0
- bitwig_agent-0.1.1.dist-info/WHEEL +4 -0
- bitwig_agent-0.1.1.dist-info/entry_points.txt +4 -0
- bitwig_agent-0.1.1.dist-info/licenses/LICENSE +21 -0
bitwig_agent/__init__.py
ADDED
bitwig_agent/__main__.py
ADDED
bitwig_agent/catalog.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The public domain catalog; no transport, MCP runtime or host initialization."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from bitwig_agent import schema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ToolSpec:
|
|
11
|
+
model: type[schema.StatusArgs]
|
|
12
|
+
description: str
|
|
13
|
+
read_only: bool
|
|
14
|
+
examples: tuple[dict[str, Any], ...]
|
|
15
|
+
|
|
16
|
+
def describe(self) -> dict[str, Any]:
|
|
17
|
+
return {"description": self.description, "readOnly": self.read_only,
|
|
18
|
+
"inputSchema": self.model.model_json_schema(), "examples": list(self.examples)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Example UUIDs are placeholders: discover real targets with tracks.list first.
|
|
22
|
+
TRACK = "12345678-1234-1234-1234-123456789abc"
|
|
23
|
+
TARGET = {"trackId": TRACK, "deviceIndex": 0, "expectedDeviceName": "Poly Grid"}
|
|
24
|
+
LAYER_PARENT = {"trackId": TRACK, "deviceIndex": 0, "expectedDeviceName": "Instrument Layer"}
|
|
25
|
+
LAYER_TARGET = {"parent": LAYER_PARENT, "layerId": "abcdef01-2345-6789-abcd-0123456789ab",
|
|
26
|
+
"childDeviceIndex": 0, "expectedChildDeviceName": "Polymer",
|
|
27
|
+
"discoveryToken": "from-devices.layers.list"}
|
|
28
|
+
CLIP = {"trackId": TRACK, "slotIndex": 0}
|
|
29
|
+
NOTE_WINDOW = {**CLIP, "channel": 0, "stepSizeBeats": 0.25, "startStep": 0,
|
|
30
|
+
"steps": 16, "pitchStart": 0, "pitchCount": 128}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def tool(model, description, read_only, *examples):
|
|
34
|
+
return ToolSpec(model, description, read_only, examples or ({},))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
TOOLS: dict[str, ToolSpec] = {
|
|
38
|
+
"status": tool(schema.StatusArgs, "Observe project/transport state, not proof of audio or persistence.", True),
|
|
39
|
+
"capabilities": tool(schema.StatusArgs, "Discover implemented host methods, bounded windows, units and unsupported interfaces. Local CLI list/describe work even without Bitwig; this method queries the running extension.", True),
|
|
40
|
+
"tracks.list": tool(schema.StatusArgs, "Discover real track UUIDs in the flat bounded track window. All our track/device/clip/channel indices are zero-based. Inspect window completeness before choosing targets; example UUIDs are not real targets.", True),
|
|
41
|
+
"tracks.get": tool(schema.DeviceArgs, "Read one track's mix state and bounded sends by UUID from tracks.list. Values explicitly named Normalized are in 0..1, not dB. Fields may be null after target changes; inspect readable, observationsComplete and sendObservationsComplete rather than treating partial observations as current values.", True, {"trackId": TRACK}),
|
|
42
|
+
"tracks.set": tool(schema.TrackSetArgs, "Set only differing fields from a fresh tracks.get snapshot, by UUID; omitted fields stay unchanged. Inspect completion and each fields entry's verification. A redundant same-value write on a rebound proxy may remain unknown without a fresh callback; never automatically retry unknown. Values named Normalized are 0..1, not dB.", False, {"trackId": TRACK, "mute": True}),
|
|
43
|
+
"tracks.select": tool(schema.DeviceArgs, "Explicitly select a track in the host UI by discovered UUID. Selection is a UI side effect; reads do not require it.", False, {"trackId": TRACK}),
|
|
44
|
+
"tracks.create": tool(schema.TrackCreateArgs, "Create and name a track. position=-1 appends; otherwise a zero-based main-track index for audio/instrument, effect-list index for effect. Inspect creation identity/verification before using the new track. An unresolved creation quarantines further creates for this extension instance (unresolved_creation); inspect actual state, never blindly retry or reset to bypass ambiguity.", False, {"type": "instrument", "position": -1, "name": "Agent Sketch"}),
|
|
45
|
+
"transport.get": tool(schema.StatusArgs, "Read observed projectName, playing, tempoBpm, positionBeats, metronome and loop settings. Recording/automation fields are arrangerRecordEnabled, arrangerAutomationWriteEnabled and clipLauncherAutomationWriteEnabled; require each explicitly false before stopped-fixture edits, not missing/null. One response covers project-label and playback-safety preflight; the name is not full-path identity or an atomic lock.", True),
|
|
46
|
+
"transport.set": tool(schema.TransportSetArgs, "Change only supplied transport fields; e.g. tempoBpm=120 playing=false. Supported tempo range 20..666 BPM is a toolkit bound. Beat positions are nonnegative; loop duration is positive and loop end finite. Playback can produce audio. Recording or automation-write mode blocks changes except an explicit playing=false stop; modes are never silently disabled.", False, {"tempoBpm": 120.0, "playing": False}),
|
|
47
|
+
"devices.list": tool(schema.DeviceArgs, "Read source-bound top-level device observations without changing host cursor/selection. Discover zero-based deviceIndex and exact expectedDeviceName, within the returned window.", True, {"trackId": TRACK}),
|
|
48
|
+
"devices.get": tool(schema.ParameterArgs, "Read a source-bound device's state and unsupported-interface details without selecting it. Use exact name and zero-based index from devices.list.", True, TARGET),
|
|
49
|
+
"devices.layers.list": tool(schema.LayerListArgs, "Discover one layer level beneath an explicit root parent without selection. Fixed zero-offset windows cover at most 2 layers and 2 child devices per layer, not a complete hierarchy. Inspect observed counts, completeness and readiness before using layerId, childDeviceIndex, exact expectedChildDeviceName and discoveryToken. Pads, slots and deeper targets are unsupported.", True, {"parent": LAYER_PARENT}),
|
|
50
|
+
"devices.set": tool(schema.DeviceSetArgs, "Enable or bypass an explicitly identified device using enabled=true/false. Inspect fresh observation or unknown outcome before another write.", False, {**TARGET, "enabled": False}),
|
|
51
|
+
"devices.select": tool(schema.ParameterArgs, "Explicitly select this device in the host UI. Unlike source-bound reads, this intentionally changes selection.", False, TARGET),
|
|
52
|
+
"devices.catalog": tool(schema.StatusArgs, "Discover installed Poly/FX/Note Grid factory device files using the host's installationRoot. Copy kind=file and path into devices.insert with an observed track/count. Only existing files are listed; no guessed community UUIDs or exhaustive plug-in inventory. File presence is not insertion or musical verification.", True),
|
|
53
|
+
"devices.insert": tool(schema.DeviceInsertArgs, "Append a native device, plug-in or preset to the track's end chain. Discover expectedDeviceCount with devices.list; truncated windows are rejected. kind=bitwig requires canonical UUID id; vst2 signed int32 decimal id; vst3 32 hex id; clap printable ASCII id. kind=file requires absolute normalized path instead of id. No shell/path expansion. An observed added device does not prove its requested native UUID: inspect requestedIdentifierVerified. Insertion can load plug-ins or produce audio; observe before retrying.", False, {"trackId": TRACK, "expectedDeviceCount": 0, "kind": "clap", "id": "org.example.synth"}),
|
|
54
|
+
"devices.remote.get": tool(schema.ParameterArgs, "Read a source-device-bound remote-control page and fresh contextToken without changing the GUI page. Discover exact page/control names and zero-based indices. Token tracks observed metadata, not unobservable same-name remaps.", True, TARGET),
|
|
55
|
+
"devices.remote.page": tool(schema.RemotePageArgs, "Change this extension's independent remote page, not the GUI-selected page. Use current contextToken and target pageIndex/expectedPageName from devices.remote.get; get a fresh token after switching.", False, {**TARGET, "contextToken": "from-devices.remote.get", "pageIndex": 0, "expectedPageName": "Main"}),
|
|
56
|
+
"devices.remote.set": tool(schema.RemoteSetArgs, "Set one normalized remote control with exact page/control names and fresh contextToken from devices.remote.get. Indices are zero-based; inspect fresh same-source value verification. Unobservable same-name mapping changes are not guaranteed detectable.", False, {**TARGET, "contextToken": "from-devices.remote.get", "expectedPageName": "Main", "parameterIndex": 0, "expectedParameterName": "Cutoff", "normalizedValue": 0.5}),
|
|
57
|
+
"parameters.list": tool(schema.ParameterArgs, "Read source-bound direct parameters and contextToken without changing host cursor/selection. Preserve trackId, deviceIndex, exact expectedDeviceName and fresh token for parameters.set. Bank context is not persistent physical-device identity.", True, TARGET),
|
|
58
|
+
"parameters.set": tool(schema.SetArgs, "Set one normalized direct parameter using the exact target, parameterId and contextToken from parameters.list. Example: value=0.5. Inspect scope and verification. Never retry an unknown outcome without inspecting state.", False, {**TARGET, "contextToken": "from-parameters.list", "parameterId": "from-parameters.list", "value": 0.5}),
|
|
59
|
+
"parameters.layer.list": tool(schema.LayerParameterArgs, "Read one discovered layer child's direct parameters and contextToken without selection. Supply the explicit parent and fresh discoveryToken from devices.layers.list. Structural ancestor changes invalidate tokens; names are metadata checks, not persistent identity. Physically rebound child sources fail closed without a genuine provenance boundary. No nested remotes, deeper layers, pads, slots or Grid-private targeting.", True, LAYER_TARGET),
|
|
60
|
+
"parameters.layer.set": tool(schema.LayerSetArgs, "Set one normalized layer-child direct parameter in 0..1 using the exact discovered target, discoveryToken and fresh contextToken/parameterId from parameters.layer.list. Inspect readiness, scope and observed verification; never retry an unknown outcome without inspecting state. No selection or GUI fallback.", False, {**LAYER_TARGET, "contextToken": "from-parameters.layer.list", "parameterId": "from-parameters.layer.list", "value": 0.5}),
|
|
61
|
+
"modulators.capabilities": tool(schema.StatusArgs, "Inspect independent primary-modulator discovery and QEE string-panel read bindings under the exact Bitwig version/loaded-JAR hash gate. writeAvailable is false; nativeVerification is not_established_by_binding. Binding availability does not establish reachable instance, DSP or native acceptance.", True),
|
|
62
|
+
"modulators.list": tool(schema.ModulatorListArgs, "Inspect primary modulators on one explicit root device without selection, loading contents or reading values. Discover parent trackId with tracks.list and deviceIndex/exact expectedDeviceName with devices.list. offset 0..100000, limit 1..128 (default 64). Rows distinguish current modulatorId, typeId, raw names and exact parameterId descriptors; unsupported children remain explicit and unavailable contents are metadata-only. No secondary/nested modulators, mappings or complete-family coverage. Pages are independent observations, not a revision-consistent cursor; parent names are checks, not durable identity.", True,
|
|
63
|
+
{"parent": {"trackId": TRACK, "deviceIndex": 0, "expectedDeviceName": "Polymer"}, "offset": 0, "limit": 64}),
|
|
64
|
+
"modulators.parameters.get": tool(schema.ModulatorParameterGetArgs, "Read one exact QEE string panel on a primary modulator of an explicit root device, without selection, mutation or content loading. Use fresh modulatorId and exact addressable parameterId from modulators.list, never names, typeId or index aliases; example UUIDs are placeholders. Reacquire current IDs after reopen or identity changes. Empty parameterId is valid when unique; IDs and raw values are bounded to 1024 UTF-16 units, never truncated. Empty strings, whitespace and Unicode are preserved; kind:string/controlFamily:string_panel with null labels/shortLabel/display. Other families reject unsupported_scope. readContext observes owning-project/context lifecycle, not active-project identity, write authorization or atomic snapshot guarantees.", True,
|
|
65
|
+
{"parent": {"trackId": TRACK, "deviceIndex": 0, "expectedDeviceName": "Polymer"}, "modulatorId": "abcdef01-2345-6789-abcd-0123456789ab", "parameterId": "LABEL"}),
|
|
66
|
+
"grid.capabilities": tool(schema.StatusArgs, "Inspect Grid bindings and the exact Bitwig 6.1.3/loaded-JAR hash gate. Reports catalog, structure, typed-parameter inspection, guarded raw parameter writes, module move/delete, moduleInsert, moduleRename and single-input connection bindings separately; unavailable write bindings do not disable reads. Binding availability is not native runtime, DSP or undo verification. Undo commands remain unsupported.", True),
|
|
67
|
+
"grid.catalog": tool(schema.GridCatalogArgs, "Read native Grid MODULE type UUIDs and names, not devices or module instances. Version/hash gated. query is a case-insensitive name substring (max 128 UTF-16 units); offset 0..100000, limit 1..256 (default 64), deterministic ordering. Inspect window completeness; catalog presence does not prove graph or parameter coverage.", True, {}, {"query": "osc", "offset": 0, "limit": 32}),
|
|
68
|
+
"grid.get": tool(schema.GridGetArgs, "Read a version/hash-gated Grid graph without selecting or changing the device. For layout, wiring or naming use includeParameters=false; discover parameter IDs with a small parameter-including module page only when needed, then grid.parameters.get before parameter CAS writes. Discover trackId with tracks.list, then deviceIndex and exact expectedDeviceName with devices.list. moduleOffset 0..100000, moduleLimit 1..128 (default 64), includeParameters defaults true. Exact QEE string panels are readable, not writable: strings preserve whitespace/Unicode, with null labels/display. String values over 1024 UTF-16 units reject the parameter-including page; includeParameters=false reads structure only. window.complete covers the module page, not all native children; structure-only reads intentionally report top-level complete=false. Inspect coverage/unsupported; instance UUID, type UUID and list-local key are different identities; no native revision or full snapshot guarantee across pages. This read performs no graph writes or native undo.", True, {**TARGET, "moduleOffset": 0, "moduleLimit": 32, "includeParameters": False}, TARGET),
|
|
69
|
+
"grid.parameters.get": tool(schema.GridParameterGetArgs, "Read one version/hash-gated Grid module parameter and native edit-context observations without selection or mutation. Use explicit trackId/deviceIndex/expectedDeviceName and a fresh grid.get module instanceId as moduleId (reacquire after project reopen), plus its exact parameterId, never a display name or type UUID. parameterId may be empty and is bounded to 1024 UTF-16 units. Exact QEE string panels report kind:string/controlFamily:string_panel, exact bounded strings and null labels/display, without a linked-consumer prerequisite; they are read-only even when supported/isSettable is true. Unrelated values are not read. editContext reports observations only, not write authorization, readiness, DSP or undo proof. This read performs no mutation or native undo; no automatic retries or GUI fallback.", True, {**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "parameterId": "from-grid.get"}),
|
|
70
|
+
"grid.parameters.set": tool(schema.GridParameterSetArgs, "Set one version/hash-gated Grid module parameter in its native RAW domain, not normalized or formatter/display units. Use an explicit current device target, fresh grid.get module instanceId as moduleId and exact parameterId; examples contain placeholder IDs, not live targets. For supported scalars or boolean panels, read grid.parameters.get immediately before writing and supply its typed kind/value as expectedKind/expectedValue. String panels are read-only and rejected, including apparent no-ops; string write kinds/values are invalid input. Exact stale-state checks precede even an already-matching no-op. Boolean requires JSON bool; integer/enum signed int32 JSON integer tokens; float finite JSON numbers. Enum values are actual listed option values, never ordinals. Native range, representability, observed stopped/nonrecording/nonautomation state and current native context are guarded; nothing is clamped or coerced. Already-matched values skip native dispatch. Result distinguishes model readback from no-op; neither proves DSP acknowledgement or one-call-one-Undo. The dispatched native operation is undo-aware, not an undo-command or grouping API. After timeout/error with unknown outcome, inspect grid.parameters.get and actual host state before another write: no retry, rollback or GUI fallback.", False,
|
|
71
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "parameterId": "from-grid.get", "expectedKind": "float", "expectedValue": 0.25, "value": 0.5},
|
|
72
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "parameterId": "from-grid.get", "expectedKind": "boolean", "expectedValue": False, "value": True}),
|
|
73
|
+
"grid.modules.insert": tool(schema.GridModuleInsertArgs, "Insert one known native MODULE type into an existing Grid device using explicit trackId/deviceIndex/exact expectedDeviceName. Discover typeId through grid.catalog: supply its canonical type UUID, not a module instance ID, name or arbitrary file path. Example IDs are placeholders, not live targets. x/y require signed int32 JSON integer tokens and specify an absolute native anchor, not pixels or deltas. Native layout may reflow existing modules; inspect the bounded actual graph, new moduleId/typeId, requestedPosition/actualPosition and matchesRequested. Module instance IDs change on project reopen: reacquire them through grid.get. This asynchronous operation reports loaderDispatched and placementDispatched separately; loading is not placement or success. Observed stopped/nonrecording/nonautomation state, native context and complete bounded geometry are required; at most 128 modules including the new module are supported. Timeout is not cancellation: native loading may outlive the request and block further topology writes. Never blindly retry an unknown outcome; inspect actual state. Availability is gated independently by moduleInsert; no GUI fallback, DSP or undo guarantee.", False,
|
|
74
|
+
{**TARGET, "typeId": "abcdef01-2345-6789-abcd-0123456789ab", "x": 4, "y": 2}),
|
|
75
|
+
"grid.modules.rename": tool(schema.GridModuleRenameArgs, "Rename one current Grid module instance UUID from fresh grid.get, using explicit trackId/deviceIndex/exact expectedDeviceName. Reacquire moduleId after reopen; examples are placeholders. expectedCustomName is the exact raw customName, never effective name or typeName, and must match before even a no-op. Both names are required JSON strings, empty allowed, at most 1024 UTF-16 units; Unicode and whitespace are preserved without coercion, trimming or truncation. customName='' resets the override through native semantics. Requires a complete bounded graph of at most 128 modules and observed stopped/nonrecording/nonautomation context. One native name setter is used only when changed, followed by later document observation and the bounded actual graph without parameters. Inspect previous/actual raw and effective names and verification. Native property bookkeeping is not proof of one Undo grouping, DSP acknowledgement, persistence or rollback. Context changes, cancellation or failure after dispatch yield unknown; inspect state rather than blindly retry. No selection or GUI fallback.", False,
|
|
76
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "expectedCustomName": "", "customName": "Lead = α"},
|
|
77
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "expectedCustomName": "Lead = α", "customName": ""}),
|
|
78
|
+
"grid.modules.move": tool(schema.GridModuleMoveArgs, "Move one current Grid module instance UUID from fresh grid.get, using explicit trackId/deviceIndex/exact expectedDeviceName. Reacquire moduleId after reopen; examples are placeholders, not live targets. expectedX/expectedY must exactly match its current native anchor before even a no-op. x/y are absolute native anchor coordinates, not deltas or pixels; all four coordinates require signed int32 JSON integer tokens. Native full-graph geometry/bounds and observed stopped/nonrecording/nonautomation context are checked before dispatch; only complete supported layouts of at most 128 modules and occupied y-span 65536 are supported. Native collision rejection and full-graph reflow may change other modules and produce an actual position different from the request. Inspect previous/requested/actual position, matchesRequested and bounded post-event graph observations: these are not DSP acknowledgement, atomicity or complete undo-group proof. No copy, GUI selection fallback, automatic retry or rollback; inspect actual state after an unknown outcome.", False,
|
|
79
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab", "expectedX": 0, "expectedY": 0, "x": 4, "y": 2}),
|
|
80
|
+
"grid.modules.delete": tool(schema.GridModuleArgs, "Selectively delete one current Grid module instance UUID from fresh grid.get, using explicit trackId/deviceIndex/exact expectedDeviceName. Reacquire moduleId after reopen; examples are placeholders, not live targets. This is not clear-all, GUI-selected deletion or missing-is-success. Retains native bypass/reconnection semantics, potentially changing the full graph. Native full-graph geometry/bounds and observed stopped/nonrecording/nonautomation context are checked before dispatch; only complete supported layouts of at most 128 modules and occupied y-span 65536 are supported. Inspect removal and bounded surviving-layout/connection observations after a later document event, not DSP acknowledgement, atomicity or complete undo-group proof. No automatic retry, rollback or GUI fallback; inspect actual state after an unknown outcome.", False,
|
|
81
|
+
{**TARGET, "moduleId": "abcdef01-2345-6789-abcd-0123456789ab"}),
|
|
82
|
+
"grid.connections.set": tool(schema.GridConnectionSetArgs, "Set one input's source in a version/hash-gated current Grid graph. Use explicit trackId/deviceIndex/exact expectedDeviceName and fresh grid.get module instance UUIDs and direction-specific port keys, not names, indices or type UUIDs. Both expectedSource and source are required: each is JSON null or exactly {moduleId,portKey}. expectedSource must match the current source before even an already-matching no-op; source=null disconnects. Keys may be empty and are bounded to 1024 UTF-16 units. Examples are placeholders, not live targets. Only a complete bounded graph of at most 128 modules and supported native input implementations are eligible; observed stopped/nonrecording/nonautomation and native edit-context safety are checked. Native repair may reject the requested link or alter other links: inspect actualSource, matchesRequested and the full bounded graph after the later document event. No automatic reconnect, rollback, retry or GUI fallback. Observations are not atomicity, DSP, undo/redo, engine or persistence proof; unknown topology outcomes quarantine further topology edits for this extension instance.", False,
|
|
83
|
+
{**TARGET, "targetModuleId": "abcdef01-2345-6789-abcd-0123456789ab", "targetPortKey": "from-grid.get-input", "expectedSource": None, "source": {"moduleId": "abcdef02-2345-6789-abcd-0123456789ab", "portKey": "from-grid.get-output"}},
|
|
84
|
+
{**TARGET, "targetModuleId": "abcdef01-2345-6789-abcd-0123456789ab", "targetPortKey": "from-grid.get-input", "expectedSource": {"moduleId": "abcdef02-2345-6789-abcd-0123456789ab", "portKey": "from-grid.get-output"}, "source": None}),
|
|
85
|
+
"launcher.list": tool(schema.DeviceArgs, "Discover occupied and empty launcher clip slots for one track UUID; slotIndex is zero-based within the bounded window.", True, {"trackId": TRACK}),
|
|
86
|
+
"launcher.create": tool(schema.ClipCreateArgs, "Create a named MIDI clip only in an empty zero-based slot. Selects that clip in the host UI to bind and verify the explicit target. Example: trackId=<UUID> slotIndex=0 lengthBeats=4 name=Sketch. Then use notes.edit with an explicit grid/window; never overwrites an existing clip.", False, {**CLIP, "lengthBeats": 4, "name": "Sketch"}),
|
|
87
|
+
"launcher.import": tool(schema.ClipImportArgs, "Import a local audio or clip file into an EMPTY explicit launcher slot by track UUID and zero-based slotIndex (0..15). Occupied slots reject; no overwrite option. path must be absolute and normalized, without URI, shell/path expansion or control characters. The host checks file existence/readability; the client checks syntax only. Result reports only observed slot content transition, not file identity or decoded audio verification. Never blindly retry an unknown outcome.", False, {**CLIP, "path": "/absolute/path/audio.wav"}),
|
|
88
|
+
"launcher.launch": tool(schema.ClipArgs, "Launch an existing clip by explicit track UUID and zero-based slot. Can start playback and produce audio. Recording and both automation-write flags must be observed false; modes are never silently disabled. Dispatch does not prove musical/audio completion.", False, CLIP),
|
|
89
|
+
"launcher.stop": tool(schema.DeviceArgs, "Stop launcher playback on one track UUID; does not delete clips or stop the entire transport.", False, {"trackId": TRACK}),
|
|
90
|
+
"notes.read": tool(schema.NoteWindowArgs, "Read an explicit clip/channel/grid/pitch window; selects the target clip in the host UI. channel is zero-based MIDI 0..15; pitch is MIDI 0..127; beat 0 is clip start. stepSizeBeats=0.25,startStep=0,steps=16 covers four beats. Inspect gridReadinessLimitation: scheduled settling is not a host-data barrier; late callbacks lack window identity. Inspect continuingAtWindowStart, notesInWindow, notesReturned and completeWindow: at most 2048 onsets returned; subdivide steps or pitchCount when incomplete. completeClip is always false; onsetPrecision is grid_cell, not arbitrary sub-grid/multiple-onset fidelity.", False, NOTE_WINDOW),
|
|
91
|
+
"notes.edit": tool(schema.NoteEditArgs, "Edit up to 256 onset cells; selects the target clip in the host UI. Each set requires op=set, absolute beat, MIDI pitch, velocity 1..127 and durationBeats; clear requires only op=clear,beat,pitch. Beat/duration must align exactly to stepSizeBeats without rounding; entire note must fit requested window and observed clip loop. No duplicate cells, overlapping same-pitch sets or clear-all; sustains are protected and already-matching cells are not rewritten. Example edits=[{\"op\":\"set\",\"beat\":0,\"pitch\":60,\"velocity\":90,\"durationBeats\":1}]. Inspect gridReadinessLimitation: scheduled settling is not a host-data barrier, and late callbacks lack window identity. Per-edit readbacks are separate from the capped 2048-onset listing. Inspect per-edit verification and completeWindow, then notes.read. No automatic retry.", False, {**NOTE_WINDOW, "edits": [{"op": "set", "beat": 0.0, "pitch": 60, "velocity": 90, "durationBeats": 1.0}]}),
|
|
92
|
+
"actions.list": tool(schema.StatusArgs, "List native host Action IDs, names and categories. Availability does not prove completion in the current UI context.", True),
|
|
93
|
+
"actions.invoke": tool(schema.ActionArgs, "Invoke one exact ID from actions.list in the current host/UI context. May edit the project or open a dialog. Returns dispatched only, never completion; inspect state before another write and do not blindly retry.", False, {"actionId": "from-actions.list"}),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def validate_call(method: str, arguments: dict[str, Any]) -> tuple[dict[str, Any], int]:
|
|
98
|
+
if method not in TOOLS:
|
|
99
|
+
raise ValueError(f"Unknown method: {method}. Use list or describe <method> offline.")
|
|
100
|
+
args = TOOLS[method].model.model_validate(arguments)
|
|
101
|
+
return args.model_dump(exclude={"timeoutMs"}, exclude_unset=True), args.timeoutMs
|
bitwig_agent/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Offline discovery and explicit one-shot calls; never starts/configures Bitwig."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from bitwig_agent.catalog import TOOLS, validate_call
|
|
12
|
+
from bitwig_agent.client import BridgeClient, BridgeError, configured_port, load_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArgumentParser(argparse.ArgumentParser):
|
|
16
|
+
def error(self, message: str) -> None:
|
|
17
|
+
raise ValueError(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parser() -> ArgumentParser:
|
|
21
|
+
result = ArgumentParser(add_help=False, allow_abbrev=False)
|
|
22
|
+
result.add_argument("command", nargs="?", default="help")
|
|
23
|
+
result.add_argument("arguments", nargs="*")
|
|
24
|
+
result.add_argument("--params", help="Strict JSON object; cannot be mixed with key=value arguments")
|
|
25
|
+
result.add_argument("--timeout-ms", type=int)
|
|
26
|
+
result.add_argument("--help", "-h", action="store_true")
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def help_payload() -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"usage": ["bitwig-agent-rpc list [domain]", "bitwig-agent-rpc describe <method>",
|
|
33
|
+
"bitwig-agent-rpc help [method]", "bitwig-agent-rpc <method> key=value ... [--timeout-ms 5000]",
|
|
34
|
+
"bitwig-agent-rpc <method> --params '{\"key\":\"value\"}'"],
|
|
35
|
+
"discovery": "list, help and describe are offline. capabilities queries the running host.",
|
|
36
|
+
"arguments": "Strings are unquoted values (shell-quote spaces); numbers/booleans/arrays/objects use strict JSON. Booleans are true/false. Explicit null is accepted only where the method schema allows it; required nullable fields must still be supplied. Duplicate/extra fields are rejected. Raw --params is equally strict.",
|
|
37
|
+
"targets": "Discover track UUIDs with tracks.list and exact device names/zero-based indices with devices.list. Clip slots and MIDI channels are zero-based. Example UUIDs are placeholders.",
|
|
38
|
+
"safety": "One request, no retries. A timeout is not cancellation; inspect actual host state before another write after an unknown outcome. Output preserves host results/errors without upgrading dispatched to observed.",
|
|
39
|
+
"exitCodes": {"0": "success or offline discovery", "1": "host/transport error", "2": "invalid arguments/configuration; not dispatched"},
|
|
40
|
+
"methods": list(TOOLS),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_arguments(method: str, pairs: list[str], raw: str | None, timeout_ms: int | None) -> tuple[dict[str, Any], int]:
|
|
45
|
+
if method not in TOOLS:
|
|
46
|
+
raise ValueError(f"Unknown method: {method}. Use list or describe <method> offline.")
|
|
47
|
+
if raw is not None:
|
|
48
|
+
if pairs:
|
|
49
|
+
raise ValueError("--params cannot be mixed with key=value arguments")
|
|
50
|
+
values = load_json(raw)
|
|
51
|
+
if not isinstance(values, dict):
|
|
52
|
+
raise ValueError("--params must be a JSON object")
|
|
53
|
+
else:
|
|
54
|
+
properties = TOOLS[method].model.model_json_schema()["properties"]
|
|
55
|
+
values = {}
|
|
56
|
+
for pair in pairs:
|
|
57
|
+
key, separator, value = pair.partition("=")
|
|
58
|
+
if not separator or not key:
|
|
59
|
+
raise ValueError(f"Expected key=value, got {pair!r}")
|
|
60
|
+
if key in values:
|
|
61
|
+
raise ValueError(f"Duplicate argument: {key}")
|
|
62
|
+
if key not in properties:
|
|
63
|
+
raise ValueError(f"Unknown argument for {method}: {key}")
|
|
64
|
+
if properties[key].get("type") == "string":
|
|
65
|
+
values[key] = load_json(value) if value.startswith('"') else value
|
|
66
|
+
else:
|
|
67
|
+
values[key] = load_json(value)
|
|
68
|
+
if timeout_ms is not None:
|
|
69
|
+
if "timeoutMs" in values:
|
|
70
|
+
raise ValueError("Specify timeout once: timeoutMs or --timeout-ms")
|
|
71
|
+
values["timeoutMs"] = timeout_ms
|
|
72
|
+
return validate_call(method, values)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def execute(argv: list[str]) -> tuple[dict[str, Any], int]:
|
|
76
|
+
"""Return the exact JSON envelope and exit code; no output side effects."""
|
|
77
|
+
try:
|
|
78
|
+
args = parser().parse_intermixed_args(argv)
|
|
79
|
+
command = args.command
|
|
80
|
+
arguments = args.arguments
|
|
81
|
+
if args.help:
|
|
82
|
+
arguments = [] if command == "help" else [command]
|
|
83
|
+
command = "help"
|
|
84
|
+
if command in {"help", "list", "describe"}:
|
|
85
|
+
if args.params is not None or args.timeout_ms is not None:
|
|
86
|
+
raise ValueError("Offline discovery does not accept --params or --timeout-ms")
|
|
87
|
+
if command == "list":
|
|
88
|
+
if len(arguments) > 1:
|
|
89
|
+
raise ValueError("Usage: list [domain]")
|
|
90
|
+
prefix = arguments[0] + "." if arguments else ""
|
|
91
|
+
entries = {name: {"description": spec.description, "readOnly": spec.read_only}
|
|
92
|
+
for name, spec in TOOLS.items() if not prefix or name.startswith(prefix)}
|
|
93
|
+
if not entries:
|
|
94
|
+
raise ValueError(f"Unknown domain: {arguments[0]}")
|
|
95
|
+
return {"result": {"methods": entries}}, 0
|
|
96
|
+
if command == "help" and not arguments:
|
|
97
|
+
return {"result": help_payload()}, 0
|
|
98
|
+
if len(arguments) != 1 or arguments[0] not in TOOLS:
|
|
99
|
+
raise ValueError("Usage: describe <known-method> (use list to discover methods)")
|
|
100
|
+
name = arguments[0]
|
|
101
|
+
return {"result": {"method": name, **TOOLS[name].describe()}}, 0
|
|
102
|
+
params, timeout_ms = parse_arguments(command, arguments, args.params, args.timeout_ms)
|
|
103
|
+
result = await BridgeClient(configured_port()).call(command, params, timeout_ms)
|
|
104
|
+
return {"result": result}, 0
|
|
105
|
+
except BridgeError as exc:
|
|
106
|
+
return {"error": exc.error}, 1
|
|
107
|
+
except (ValueError, TypeError, RecursionError) as exc:
|
|
108
|
+
return {"error": {"code": "invalid_params", "message": str(exc), "outcome": "not_dispatched"}}, 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main(argv: list[str] | None = None) -> int:
|
|
112
|
+
payload, exit_code = asyncio.run(execute(sys.argv[1:] if argv is None else argv))
|
|
113
|
+
print(json.dumps(payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")))
|
|
114
|
+
return exit_code
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|
bitwig_agent/client.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""One newline-delimited JSON request per loopback connection, without retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any
|
|
11
|
+
from bitwig_agent.catalog import TOOLS, validate_call
|
|
12
|
+
|
|
13
|
+
MAX_BYTES = 1024 * 1024
|
|
14
|
+
METHODS = frozenset(TOOLS)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BridgeError(Exception):
|
|
18
|
+
"""Keep host error fields, including an unknown outcome, intact."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, error: dict[str, Any]):
|
|
21
|
+
self.error = error
|
|
22
|
+
super().__init__(error["message"])
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _reject_constant(value: str) -> None:
|
|
26
|
+
raise ValueError(f"Non-finite JSON number: {value}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _finite_float(value: str) -> float:
|
|
30
|
+
parsed = float(value)
|
|
31
|
+
if not math.isfinite(parsed):
|
|
32
|
+
raise ValueError("JSON number is outside the finite float range")
|
|
33
|
+
return parsed
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
37
|
+
result = {}
|
|
38
|
+
for key, value in pairs:
|
|
39
|
+
if key in result:
|
|
40
|
+
raise ValueError(f"Duplicate JSON field: {key}")
|
|
41
|
+
result[key] = value
|
|
42
|
+
return result
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_json(value: str) -> Any:
|
|
46
|
+
"""Decode strict JSON, rejecting duplicate keys and non-finite numbers."""
|
|
47
|
+
return json.loads(value, parse_constant=_reject_constant, parse_float=_finite_float, object_pairs_hook=_unique_object)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def configured_port() -> int:
|
|
51
|
+
value = int(os.environ.get("BITWIG_AGENT_PORT", "61172"))
|
|
52
|
+
if not 1 <= value <= 65535:
|
|
53
|
+
raise ValueError("BITWIG_AGENT_PORT must be in 1..65535")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BridgeClient:
|
|
58
|
+
def __init__(self, port: int = 61172, *, response_grace_seconds: float = 1.0):
|
|
59
|
+
if type(port) is not int or not 1 <= port <= 65535:
|
|
60
|
+
raise ValueError("port must be in 1..65535")
|
|
61
|
+
self.port = port
|
|
62
|
+
self.response_grace_seconds = response_grace_seconds
|
|
63
|
+
|
|
64
|
+
async def call(self, method: str, params: dict[str, Any], timeout_ms: int = 5000) -> dict[str, Any]:
|
|
65
|
+
if method not in METHODS or not isinstance(params, dict):
|
|
66
|
+
raise ValueError("A known method and object params are required")
|
|
67
|
+
if type(timeout_ms) is not int or not 100 <= timeout_ms <= 10000:
|
|
68
|
+
raise ValueError("timeoutMs must be an integer in 100..10000")
|
|
69
|
+
if "timeoutMs" in params:
|
|
70
|
+
raise ValueError("Pass timeout_ms separately from host params")
|
|
71
|
+
params, _ = validate_call(method, {**params, "timeoutMs": timeout_ms})
|
|
72
|
+
request_id = uuid.uuid4().hex
|
|
73
|
+
request = {"id": request_id, "method": method, "params": params, "timeoutMs": timeout_ms}
|
|
74
|
+
payload = (json.dumps(request, allow_nan=False, separators=(",", ":")) + "\n").encode("utf-8")
|
|
75
|
+
if len(payload) > MAX_BYTES:
|
|
76
|
+
raise ValueError("Request exceeds 1 MiB")
|
|
77
|
+
|
|
78
|
+
writer = None
|
|
79
|
+
dispatched = False
|
|
80
|
+
try:
|
|
81
|
+
# The host deadline bounds mutations. The extra second only lets its
|
|
82
|
+
# final response reach us; it never extends or replays the request.
|
|
83
|
+
async with asyncio.timeout(timeout_ms / 1000 + self.response_grace_seconds):
|
|
84
|
+
reader, writer = await asyncio.open_connection("127.0.0.1", self.port, limit=MAX_BYTES)
|
|
85
|
+
dispatched = True
|
|
86
|
+
writer.write(payload)
|
|
87
|
+
await writer.drain()
|
|
88
|
+
raw = await reader.readuntil(b"\n")
|
|
89
|
+
if len(raw) > MAX_BYTES:
|
|
90
|
+
raise ValueError("Response exceeds 1 MiB")
|
|
91
|
+
response = load_json(raw.decode("utf-8"))
|
|
92
|
+
if not isinstance(response, dict) or response.get("id") != request_id:
|
|
93
|
+
raise ValueError("Response id does not match request")
|
|
94
|
+
if ("result" in response) == ("error" in response):
|
|
95
|
+
raise ValueError("Response must contain exactly one of result or error")
|
|
96
|
+
if "error" in response:
|
|
97
|
+
error = response["error"]
|
|
98
|
+
if not isinstance(error, dict) or not isinstance(error.get("code"), str) or not isinstance(error.get("message"), str) or error.get("outcome") not in ("not_dispatched", "not_applied", "unknown"):
|
|
99
|
+
raise ValueError("Malformed host error")
|
|
100
|
+
raise BridgeError(error)
|
|
101
|
+
if not isinstance(response["result"], dict):
|
|
102
|
+
raise ValueError("Host result must be an object")
|
|
103
|
+
return response["result"]
|
|
104
|
+
except BridgeError:
|
|
105
|
+
raise
|
|
106
|
+
except (TimeoutError, OSError, ValueError, RecursionError, asyncio.IncompleteReadError, asyncio.LimitOverrunError) as exc:
|
|
107
|
+
outcome = "unknown" if dispatched else "not_dispatched"
|
|
108
|
+
code = "deadline_exceeded" if isinstance(exc, TimeoutError) else "transport_error"
|
|
109
|
+
raise BridgeError({"code": code, "message": f"{type(exc).__name__}: {exc}. Inspect host state before another write; request was not retried.", "outcome": outcome}) from exc
|
|
110
|
+
finally:
|
|
111
|
+
if writer is not None:
|
|
112
|
+
writer.close()
|
|
113
|
+
try:
|
|
114
|
+
await asyncio.wait_for(writer.wait_closed(), timeout=0.2)
|
|
115
|
+
except (OSError, TimeoutError):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
|
bitwig_agent/install.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Install the matching release extension, or an explicitly supplied local build."""
|
|
2
|
+
import argparse
|
|
3
|
+
import hashlib
|
|
4
|
+
from http.client import HTTPException
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from urllib.error import URLError
|
|
14
|
+
from urllib.request import urlopen
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_EXTENSION = "BitwigAgent.bwextension"
|
|
18
|
+
_RELEASES = "https://github.com/BANANASJIM/bitwig-agent/releases/download"
|
|
19
|
+
_MAX_EXTENSION_BYTES = 32 * 1024 * 1024
|
|
20
|
+
_MAX_CHECKSUM_BYTES = 64 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _default_destination() -> Path:
|
|
24
|
+
base = Path.home()
|
|
25
|
+
if sys.platform in ("darwin", "win32"):
|
|
26
|
+
base /= "Documents"
|
|
27
|
+
return base / "Bitwig Studio" / "Extensions"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _download(url: str, limit: int) -> bytes:
|
|
31
|
+
deadline = time.monotonic() + 60
|
|
32
|
+
with urlopen(url, timeout=30) as response:
|
|
33
|
+
chunks = []
|
|
34
|
+
size = 0
|
|
35
|
+
while True:
|
|
36
|
+
if time.monotonic() >= deadline:
|
|
37
|
+
raise ValueError(f"Download timed out: {url}")
|
|
38
|
+
chunk = response.read1(min(64 * 1024, limit + 1 - size))
|
|
39
|
+
if not chunk:
|
|
40
|
+
break
|
|
41
|
+
chunks.append(chunk)
|
|
42
|
+
size += len(chunk)
|
|
43
|
+
if size > limit:
|
|
44
|
+
raise ValueError(f"Download exceeds {limit} bytes: {url}")
|
|
45
|
+
length = response.headers.get("Content-Length")
|
|
46
|
+
if length is not None and size != int(length):
|
|
47
|
+
raise ValueError(f"Incomplete download: {url}")
|
|
48
|
+
return b"".join(chunks)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _release_extension(package_version: str) -> bytes:
|
|
52
|
+
base = f"{_RELEASES}/v{package_version}"
|
|
53
|
+
manifest = _download(f"{base}/SHA256SUMS", _MAX_CHECKSUM_BYTES).decode("utf-8")
|
|
54
|
+
checksums = []
|
|
55
|
+
for line in manifest.splitlines():
|
|
56
|
+
if not line.strip():
|
|
57
|
+
continue
|
|
58
|
+
match = re.fullmatch(r"([0-9a-fA-F]{64}) [ *]([^\r\n]+)", line)
|
|
59
|
+
if match is None:
|
|
60
|
+
raise ValueError("Malformed SHA256SUMS entry")
|
|
61
|
+
digest, filename = match.groups()
|
|
62
|
+
if filename == _EXTENSION:
|
|
63
|
+
checksums.append(digest.lower())
|
|
64
|
+
if len(checksums) != 1:
|
|
65
|
+
raise ValueError(f"SHA256SUMS must contain exactly one entry for {_EXTENSION}")
|
|
66
|
+
data = _download(f"{base}/{_EXTENSION}", _MAX_EXTENSION_BYTES)
|
|
67
|
+
if hashlib.sha256(data).hexdigest() != checksums[0]:
|
|
68
|
+
raise ValueError(f"SHA256 mismatch for {_EXTENSION}; installation unchanged")
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _atomic_write(target: Path, data: bytes) -> None:
|
|
73
|
+
staging = None
|
|
74
|
+
try:
|
|
75
|
+
with tempfile.NamedTemporaryFile(prefix=f".{target.name}.", suffix=".tmp", dir=target.parent, delete=False) as stream:
|
|
76
|
+
staging = Path(stream.name)
|
|
77
|
+
stream.write(data)
|
|
78
|
+
stream.flush()
|
|
79
|
+
os.fsync(stream.fileno())
|
|
80
|
+
staging.replace(target)
|
|
81
|
+
finally:
|
|
82
|
+
if staging is not None:
|
|
83
|
+
staging.unlink(missing_ok=True)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _install(data: bytes, destination: Path) -> None:
|
|
87
|
+
target = destination / _EXTENSION
|
|
88
|
+
if target.exists():
|
|
89
|
+
old = target.read_bytes()
|
|
90
|
+
if old == data:
|
|
91
|
+
print(f"Already installed: {target}")
|
|
92
|
+
return
|
|
93
|
+
# Bitwig scans recursively: backups must live outside Extensions.
|
|
94
|
+
backup = destination.parent / ".bitwig-agent-backups" / hashlib.sha256(old).hexdigest() / _EXTENSION
|
|
95
|
+
if backup.exists():
|
|
96
|
+
if backup.read_bytes() != old:
|
|
97
|
+
raise ValueError(f"Existing backup has unexpected contents: {backup}")
|
|
98
|
+
else:
|
|
99
|
+
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
_atomic_write(backup, old)
|
|
101
|
+
print(f"Previous build preserved: {backup}")
|
|
102
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
_atomic_write(target, data)
|
|
104
|
+
print(f"Installed: {target}")
|
|
105
|
+
print("Enable Bitwig Agent under Bitwig Settings > Controllers; no MIDI ports are required.")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main(argv: list[str] | None = None) -> None:
|
|
109
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
110
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('bitwig-agent')}")
|
|
111
|
+
parser.add_argument("--extension", type=Path, help="Install this local extension instead of downloading a release")
|
|
112
|
+
parser.add_argument("--destination", type=Path, default=_default_destination(), help="Bitwig Extensions directory")
|
|
113
|
+
parser.add_argument("--mcp-config", action="store_true", help="Print MCP configuration only; do not download or install")
|
|
114
|
+
args = parser.parse_args(argv)
|
|
115
|
+
if args.mcp_config:
|
|
116
|
+
print(json.dumps({"mcpServers": {"bitwig-agent": {
|
|
117
|
+
"command": sys.executable,
|
|
118
|
+
"args": ["-m", "bitwig_agent"],
|
|
119
|
+
"env": {"BITWIG_AGENT_PORT": "61172"},
|
|
120
|
+
}}}, indent=2))
|
|
121
|
+
return
|
|
122
|
+
try:
|
|
123
|
+
data = args.extension.read_bytes() if args.extension is not None else _release_extension(version("bitwig-agent"))
|
|
124
|
+
_install(data, args.destination)
|
|
125
|
+
except (OSError, ValueError, URLError, HTTPException) as exc:
|
|
126
|
+
parser.exit(1, f"{parser.prog}: error: {exc}\n")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
bitwig_agent/schema.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Strict public arguments shared by MCP, the diagnostic CLI and TCP callers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import PurePosixPath
|
|
9
|
+
from typing import Annotated, Literal
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
12
|
+
|
|
13
|
+
Nonempty = Annotated[str, Field(min_length=1, max_length=4096)]
|
|
14
|
+
TrackId = Annotated[str, Field(pattern=r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")]
|
|
15
|
+
UnitValue = Annotated[float, Field(ge=0, le=1)]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def omitted():
|
|
19
|
+
"""Absent is allowed; explicit null is not part of the wire contract."""
|
|
20
|
+
return Field(default=None, json_schema_extra=lambda schema: schema.pop("default", None))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_local_path(value: str) -> None:
|
|
24
|
+
path = PurePosixPath(value)
|
|
25
|
+
if not path.is_absolute() or value.startswith("//") or str(path) != value or ".." in path.parts or any(ord(c) < 32 or 127 <= ord(c) <= 159 for c in value):
|
|
26
|
+
raise ValueError("path must be an absolute normalized path without control characters")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StrictModel(BaseModel):
|
|
30
|
+
model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)
|
|
31
|
+
|
|
32
|
+
@model_validator(mode="after")
|
|
33
|
+
def validate_host_string_lengths(self):
|
|
34
|
+
# Java String.length counts UTF-16 units, unlike JSON Schema maxLength.
|
|
35
|
+
for key, field in type(self).model_fields.items():
|
|
36
|
+
value = getattr(self, key)
|
|
37
|
+
if isinstance(value, str):
|
|
38
|
+
length = len(value.encode("utf-16-le", errors="surrogatepass")) // 2
|
|
39
|
+
for constraint in field.metadata:
|
|
40
|
+
limit = getattr(constraint, "max_length", None)
|
|
41
|
+
if limit is not None and length > limit:
|
|
42
|
+
raise ValueError(f"{key} exceeds {limit} UTF-16 code units")
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StatusArgs(StrictModel):
|
|
47
|
+
timeoutMs: int = Field(default=5000, ge=100, le=10000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DeviceArgs(StatusArgs):
|
|
51
|
+
trackId: TrackId
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ActionArgs(StatusArgs):
|
|
55
|
+
actionId: Nonempty
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ParameterArgs(DeviceArgs):
|
|
59
|
+
deviceIndex: int = Field(ge=0, le=15)
|
|
60
|
+
expectedDeviceName: Nonempty
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DeviceParent(StrictModel):
|
|
64
|
+
trackId: TrackId
|
|
65
|
+
deviceIndex: int = Field(ge=0, le=15)
|
|
66
|
+
expectedDeviceName: Nonempty
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LayerListArgs(StatusArgs):
|
|
70
|
+
parent: DeviceParent
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class LayerParameterArgs(LayerListArgs):
|
|
74
|
+
layerId: TrackId
|
|
75
|
+
childDeviceIndex: int = Field(ge=0, le=1)
|
|
76
|
+
expectedChildDeviceName: Nonempty
|
|
77
|
+
discoveryToken: Nonempty
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LayerSetArgs(LayerParameterArgs):
|
|
81
|
+
contextToken: Nonempty
|
|
82
|
+
parameterId: Nonempty
|
|
83
|
+
value: UnitValue
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ModulatorListArgs(StatusArgs):
|
|
87
|
+
parent: DeviceParent
|
|
88
|
+
offset: int = Field(default=0, ge=0, le=100000)
|
|
89
|
+
limit: int = Field(default=64, ge=1, le=128)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ModulatorParameterGetArgs(StatusArgs):
|
|
93
|
+
parent: DeviceParent
|
|
94
|
+
modulatorId: TrackId = Field(description="Current modulator UUID from modulators.list, not its typeId, name or index; reacquire after reopening or identity changes.")
|
|
95
|
+
parameterId: str = Field(max_length=1024, description="Exact addressable parameterId from modulators.list, not a display name; empty is valid when unique, at most 1024 UTF-16 code units.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GridCatalogArgs(StatusArgs):
|
|
99
|
+
query: str = Field(default="", max_length=128)
|
|
100
|
+
offset: int = Field(default=0, ge=0, le=100000)
|
|
101
|
+
limit: int = Field(default=64, ge=1, le=256)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class GridGetArgs(ParameterArgs):
|
|
105
|
+
moduleOffset: int = Field(default=0, ge=0, le=100000)
|
|
106
|
+
moduleLimit: int = Field(default=64, ge=1, le=128)
|
|
107
|
+
includeParameters: bool = True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GridModuleInsertArgs(ParameterArgs):
|
|
111
|
+
typeId: TrackId = Field(description="Canonical MODULE type UUID discovered through grid.catalog, not a module instanceId or file path; the host verifies catalog membership.")
|
|
112
|
+
x: int = Field(ge=-(2**31), le=2**31 - 1, description="Requested absolute native anchor X, not a relative delta or pixels. Signed int32 JSON integer token; native layout bounds checked by host.")
|
|
113
|
+
y: int = Field(ge=-(2**31), le=2**31 - 1, description="Requested absolute native anchor Y, not a relative delta or pixels. Signed int32 JSON integer token; native layout bounds checked by host.")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class GridModuleArgs(ParameterArgs):
|
|
117
|
+
moduleId: TrackId = Field(description="Current module instanceId from grid.get; reacquire after reopening the project.")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class GridConnectionSource(StrictModel):
|
|
121
|
+
moduleId: TrackId = Field(description="Current source module instanceId from grid.get, not a type UUID.")
|
|
122
|
+
portKey: str = Field(max_length=1024, description="Exact output port key from grid.get; empty is valid, at most 1024 UTF-16 code units.")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class GridConnectionSetArgs(ParameterArgs):
|
|
126
|
+
targetModuleId: TrackId = Field(description="Current destination module instanceId from grid.get; reacquire after reopening the project.")
|
|
127
|
+
targetPortKey: str = Field(max_length=1024, description="Exact input port key from grid.get; empty is valid, at most 1024 UTF-16 code units.")
|
|
128
|
+
expectedSource: GridConnectionSource | None = Field(description="Required exact prior source from fresh grid.get, or explicit null for an unconnected input. Checked before even an already-matching no-op; never defaults to null.")
|
|
129
|
+
source: GridConnectionSource | None = Field(description="Required requested output endpoint, or explicit null to disconnect. Must belong to the same current graph; native repair can change the observed result.")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class GridModuleRenameArgs(GridModuleArgs):
|
|
133
|
+
expectedCustomName: str = Field(max_length=1024, description="Exact raw customName from a fresh grid.get, not effective name or typeName. Required even for no-op; empty is valid. At most 1024 UTF-16 code units; no trimming or coercion.")
|
|
134
|
+
customName: str = Field(max_length=1024, description="Requested raw custom name; empty resets the override through native semantics. Unicode and whitespace are preserved, at most 1024 UTF-16 code units.")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class GridModuleMoveArgs(GridModuleArgs):
|
|
138
|
+
expectedX: int = Field(ge=-(2**31), le=2**31 - 1, description="Fresh native anchor X; exact stale-state check precedes even a no-op. Signed int32 JSON integer token.")
|
|
139
|
+
expectedY: int = Field(ge=-(2**31), le=2**31 - 1, description="Fresh native anchor Y; exact stale-state check precedes even a no-op. Signed int32 JSON integer token.")
|
|
140
|
+
x: int = Field(ge=-(2**31), le=2**31 - 1, description="Requested absolute native anchor X, not a relative delta or pixels. Signed int32 JSON integer token; native layout bounds checked by host.")
|
|
141
|
+
y: int = Field(ge=-(2**31), le=2**31 - 1, description="Requested absolute native anchor Y, not a relative delta or pixels. Signed int32 JSON integer token; native layout bounds checked by host.")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class GridParameterGetArgs(GridModuleArgs):
|
|
145
|
+
parameterId: str = Field(max_length=1024, description="Exact native parameter ID from grid.get, not a name; empty is valid, at most 1024 UTF-16 code units.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class GridParameterSetArgs(GridParameterGetArgs):
|
|
149
|
+
model_config = ConfigDict(json_schema_extra={
|
|
150
|
+
"oneOf": [
|
|
151
|
+
{"properties": {
|
|
152
|
+
"expectedKind": {"enum": kinds},
|
|
153
|
+
"expectedValue": value_schema,
|
|
154
|
+
"value": value_schema,
|
|
155
|
+
}}
|
|
156
|
+
for kinds, value_schema in (
|
|
157
|
+
(["boolean"], {"type": "boolean"}),
|
|
158
|
+
(["integer", "enum"], {"type": "integer", "minimum": -(2**31), "maximum": 2**31 - 1}),
|
|
159
|
+
(["float"], {"type": "number"}),
|
|
160
|
+
)
|
|
161
|
+
],
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
expectedKind: Literal["float", "integer", "enum", "boolean"] = Field(
|
|
165
|
+
description="Fresh typed kind from grid.parameters.get; checked before any already-matching no-op.")
|
|
166
|
+
expectedValue: bool | int | float = Field(
|
|
167
|
+
description="Exact current native raw value from a fresh grid.parameters.get, not normalized or display units. Must match expectedKind; no tolerance or coercion.")
|
|
168
|
+
value: bool | int | float = Field(
|
|
169
|
+
description="Requested native raw value: boolean JSON bool; integer/enum signed int32 JSON integer token; float finite JSON number. Enum uses an actual listed option value, never an ordinal. Native range/representability are checked before dispatch.")
|
|
170
|
+
|
|
171
|
+
@field_validator("expectedValue", "value", mode="before")
|
|
172
|
+
@classmethod
|
|
173
|
+
def validate_raw_number(cls, value):
|
|
174
|
+
if type(value) not in (bool, int, float):
|
|
175
|
+
raise ValueError("raw value must be a JSON boolean or number, never null or a string")
|
|
176
|
+
if type(value) is not bool:
|
|
177
|
+
try:
|
|
178
|
+
finite = math.isfinite(value)
|
|
179
|
+
except OverflowError:
|
|
180
|
+
finite = False
|
|
181
|
+
if not finite:
|
|
182
|
+
raise ValueError("raw numeric value must be finite and representable as a native double")
|
|
183
|
+
return value
|
|
184
|
+
|
|
185
|
+
@model_validator(mode="after")
|
|
186
|
+
def validate_raw_kind(self):
|
|
187
|
+
for field in ("expectedValue", "value"):
|
|
188
|
+
value = getattr(self, field)
|
|
189
|
+
if self.expectedKind == "boolean":
|
|
190
|
+
if type(value) is not bool:
|
|
191
|
+
raise ValueError(f"{field} must be a JSON boolean for boolean kind")
|
|
192
|
+
elif self.expectedKind in ("integer", "enum"):
|
|
193
|
+
if type(value) is not int or not -(2**31) <= value < 2**31:
|
|
194
|
+
raise ValueError(f"{field} must be a signed int32 JSON integer for {self.expectedKind} kind")
|
|
195
|
+
elif type(value) not in (int, float):
|
|
196
|
+
raise ValueError(f"{field} must be a finite JSON number for float kind")
|
|
197
|
+
return self
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class SetArgs(ParameterArgs):
|
|
201
|
+
contextToken: Nonempty
|
|
202
|
+
parameterId: Nonempty
|
|
203
|
+
value: UnitValue
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class DeviceSetArgs(ParameterArgs):
|
|
207
|
+
enabled: bool
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DeviceInsertArgs(DeviceArgs):
|
|
211
|
+
expectedDeviceCount: int = Field(ge=0, le=15)
|
|
212
|
+
kind: Literal["bitwig", "vst2", "vst3", "clap", "file"]
|
|
213
|
+
id: Nonempty = omitted()
|
|
214
|
+
path: Nonempty = omitted()
|
|
215
|
+
|
|
216
|
+
@model_validator(mode="after")
|
|
217
|
+
def validate_identifier(self):
|
|
218
|
+
if self.kind == "file":
|
|
219
|
+
if "id" in self.model_fields_set or "path" not in self.model_fields_set:
|
|
220
|
+
raise ValueError("file insertion requires path and forbids id")
|
|
221
|
+
validate_local_path(self.path)
|
|
222
|
+
else:
|
|
223
|
+
if "path" in self.model_fields_set or "id" not in self.model_fields_set:
|
|
224
|
+
raise ValueError("plug-in insertion requires id and forbids path")
|
|
225
|
+
if self.kind == "bitwig" and not re.fullmatch(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}", self.id):
|
|
226
|
+
raise ValueError("bitwig id must be a canonical UUID")
|
|
227
|
+
if self.kind == "vst2" and (not re.fullmatch(r"-?(0|[1-9][0-9]{0,9})", self.id) or not -(2**31) <= int(self.id) < 2**31):
|
|
228
|
+
raise ValueError("vst2 id must be a signed int32 decimal string")
|
|
229
|
+
if self.kind == "vst3" and not re.fullmatch(r"[0-9a-fA-F]{32}", self.id):
|
|
230
|
+
raise ValueError("vst3 id must be 32 hexadecimal characters")
|
|
231
|
+
if self.kind == "clap" and not re.fullmatch(r"[!-~]{1,256}", self.id):
|
|
232
|
+
raise ValueError("clap id must be 1..256 printable non-whitespace ASCII characters")
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ClipArgs(DeviceArgs):
|
|
237
|
+
slotIndex: int = Field(ge=0, le=15)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class ClipCreateArgs(ClipArgs):
|
|
241
|
+
lengthBeats: int = Field(ge=1, le=4096)
|
|
242
|
+
name: str = Field(min_length=1, max_length=128)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ClipImportArgs(ClipArgs):
|
|
246
|
+
path: Nonempty
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def validate_path(self):
|
|
250
|
+
validate_local_path(self.path)
|
|
251
|
+
return self
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class NoteWindowArgs(ClipArgs):
|
|
255
|
+
channel: int = Field(ge=0, le=15)
|
|
256
|
+
stepSizeBeats: float = Field(ge=1 / 128, le=4)
|
|
257
|
+
startStep: int = Field(ge=0, le=1048576)
|
|
258
|
+
steps: int = Field(ge=1, le=128)
|
|
259
|
+
pitchStart: int = Field(ge=0, le=127)
|
|
260
|
+
pitchCount: int = Field(ge=1, le=128)
|
|
261
|
+
|
|
262
|
+
@model_validator(mode="after")
|
|
263
|
+
def validate_pitch_window(self):
|
|
264
|
+
if self.pitchStart + self.pitchCount > 128:
|
|
265
|
+
raise ValueError("pitchStart + pitchCount must not exceed 128")
|
|
266
|
+
return self
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class NoteEdit(StrictModel):
|
|
270
|
+
op: Literal["set", "clear"]
|
|
271
|
+
beat: float = Field(ge=0, le=4194304)
|
|
272
|
+
pitch: int = Field(ge=0, le=127)
|
|
273
|
+
velocity: Annotated[int, Field(ge=1, le=127)] = omitted()
|
|
274
|
+
durationBeats: Annotated[float, Field(ge=1 / 128, le=4096)] = omitted()
|
|
275
|
+
|
|
276
|
+
@model_validator(mode="after")
|
|
277
|
+
def validate_operation(self):
|
|
278
|
+
supplied = self.model_fields_set & {"velocity", "durationBeats"}
|
|
279
|
+
if self.op == "set" and supplied != {"velocity", "durationBeats"}:
|
|
280
|
+
raise ValueError("set requires velocity and durationBeats")
|
|
281
|
+
if self.op == "clear" and supplied:
|
|
282
|
+
raise ValueError("clear forbids velocity and durationBeats")
|
|
283
|
+
return self
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class NoteEditArgs(NoteWindowArgs):
|
|
287
|
+
edits: list[NoteEdit] = Field(min_length=1, max_length=256)
|
|
288
|
+
|
|
289
|
+
@model_validator(mode="after")
|
|
290
|
+
def validate_edits(self):
|
|
291
|
+
cells = set()
|
|
292
|
+
intervals: dict[int, list[tuple[int, int]]] = {}
|
|
293
|
+
grid = Decimal(str(self.stepSizeBeats))
|
|
294
|
+
for edit in self.edits:
|
|
295
|
+
beat = Decimal(str(edit.beat))
|
|
296
|
+
if beat % grid:
|
|
297
|
+
raise ValueError("beat must align exactly with stepSizeBeats")
|
|
298
|
+
step = int(beat / grid)
|
|
299
|
+
if not self.startStep <= step < self.startStep + self.steps:
|
|
300
|
+
raise ValueError("edit beat is outside the requested step window")
|
|
301
|
+
if not self.pitchStart <= edit.pitch < self.pitchStart + self.pitchCount:
|
|
302
|
+
raise ValueError("edit pitch is outside the requested pitch window")
|
|
303
|
+
cell = (step, edit.pitch)
|
|
304
|
+
if cell in cells:
|
|
305
|
+
raise ValueError("duplicate edit cell")
|
|
306
|
+
cells.add(cell)
|
|
307
|
+
if edit.op == "set":
|
|
308
|
+
duration = Decimal(str(edit.durationBeats))
|
|
309
|
+
if duration % grid:
|
|
310
|
+
raise ValueError("durationBeats must align exactly with stepSizeBeats")
|
|
311
|
+
end = step + int(duration / grid)
|
|
312
|
+
if end > self.startStep + self.steps:
|
|
313
|
+
raise ValueError("set duration extends beyond the requested step window")
|
|
314
|
+
spans = intervals.setdefault(edit.pitch, [])
|
|
315
|
+
if any(step < old_end and start < end for start, old_end in spans):
|
|
316
|
+
raise ValueError("same-pitch set durations overlap")
|
|
317
|
+
spans.append((step, end))
|
|
318
|
+
return self
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class TransportSetArgs(StatusArgs):
|
|
322
|
+
playing: bool = omitted()
|
|
323
|
+
tempoBpm: Annotated[float, Field(ge=20, le=666)] = omitted()
|
|
324
|
+
positionBeats: Annotated[float, Field(ge=0)] = omitted()
|
|
325
|
+
metronome: bool = omitted()
|
|
326
|
+
loopEnabled: bool = omitted()
|
|
327
|
+
loopStartBeats: Annotated[float, Field(ge=0)] = omitted()
|
|
328
|
+
loopDurationBeats: Annotated[float, Field(gt=0)] = omitted()
|
|
329
|
+
|
|
330
|
+
@model_validator(mode="after")
|
|
331
|
+
def require_change(self):
|
|
332
|
+
if not self.model_fields_set - {"timeoutMs"}:
|
|
333
|
+
raise ValueError("transport.set requires at least one changed field")
|
|
334
|
+
if {"loopStartBeats", "loopDurationBeats"} <= self.model_fields_set:
|
|
335
|
+
if not math.isfinite(self.loopStartBeats + self.loopDurationBeats):
|
|
336
|
+
raise ValueError("loopStartBeats + loopDurationBeats must be finite")
|
|
337
|
+
return self
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class TrackSetArgs(DeviceArgs):
|
|
341
|
+
name: Nonempty = omitted()
|
|
342
|
+
volumeNormalized: UnitValue = omitted()
|
|
343
|
+
panNormalized: UnitValue = omitted()
|
|
344
|
+
mute: bool = omitted()
|
|
345
|
+
solo: bool = omitted()
|
|
346
|
+
arm: bool = omitted()
|
|
347
|
+
|
|
348
|
+
@model_validator(mode="after")
|
|
349
|
+
def require_change(self):
|
|
350
|
+
if not self.model_fields_set - {"timeoutMs", "trackId"}:
|
|
351
|
+
raise ValueError("tracks.set requires at least one changed field")
|
|
352
|
+
return self
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class TrackCreateArgs(StatusArgs):
|
|
356
|
+
type: Literal["audio", "instrument", "effect"]
|
|
357
|
+
position: int = Field(ge=-1, le=2147483647)
|
|
358
|
+
name: Nonempty
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class RemotePageArgs(ParameterArgs):
|
|
362
|
+
contextToken: Nonempty
|
|
363
|
+
pageIndex: int = Field(ge=0, le=4095)
|
|
364
|
+
expectedPageName: Nonempty
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class RemoteSetArgs(ParameterArgs):
|
|
368
|
+
contextToken: Nonempty
|
|
369
|
+
expectedPageName: Nonempty
|
|
370
|
+
parameterIndex: int = Field(ge=0, le=7)
|
|
371
|
+
expectedParameterName: Nonempty
|
|
372
|
+
normalizedValue: UnitValue
|
bitwig_agent/server.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""MCP stdio adapter over the shared domain catalog and loopback client.
|
|
2
|
+
|
|
3
|
+
Public SDK APIs: https://github.com/modelcontextprotocol/python-sdk/tree/v1.29.1
|
|
4
|
+
Importing this module does not create a server or configure any global client.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mcp.server import Server
|
|
14
|
+
from mcp.server.stdio import stdio_server
|
|
15
|
+
from mcp.types import CallToolResult, TextContent, Tool, ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from bitwig_agent.catalog import TOOLS, validate_call
|
|
18
|
+
from bitwig_agent.client import BridgeClient, BridgeError, configured_port
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_server(client: BridgeClient | None = None) -> Server:
|
|
22
|
+
bridge = client if client is not None else BridgeClient(configured_port())
|
|
23
|
+
server = Server("Bitwig Agent", version="0.1.1", instructions="Discover capabilities and tracks.list first. Our indices are zero-based. Use exact discovered targets and inspect bounded windows and verification. Timeouts are not cancellation; reconcile unknown outcomes before another write. No automatic retries. The agent may use Computer Use for API gaps; never blindly repeat an uncertain write through the GUI.")
|
|
24
|
+
|
|
25
|
+
@server.list_tools()
|
|
26
|
+
async def list_tools() -> list[Tool]:
|
|
27
|
+
return [
|
|
28
|
+
Tool(name=name, description=spec.description + " Example arguments: " + json.dumps(spec.examples[0], ensure_ascii=False),
|
|
29
|
+
inputSchema=spec.model.model_json_schema(),
|
|
30
|
+
annotations=ToolAnnotations(readOnlyHint=spec.read_only, destructiveHint=not spec.read_only, idempotentHint=False))
|
|
31
|
+
for name, spec in TOOLS.items()
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# The shared strict validator also enforces cross-field host invariants.
|
|
35
|
+
# Handle its errors ourselves so MCP and CLI both return JSON envelopes.
|
|
36
|
+
@server.call_tool(validate_input=False)
|
|
37
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult:
|
|
38
|
+
try:
|
|
39
|
+
params, timeout_ms = validate_call(name, arguments)
|
|
40
|
+
payload = {"result": await bridge.call(name, params, timeout_ms)}
|
|
41
|
+
failed = False
|
|
42
|
+
except BridgeError as exc:
|
|
43
|
+
payload = {"error": exc.error}
|
|
44
|
+
failed = True
|
|
45
|
+
except (ValueError, TypeError) as exc:
|
|
46
|
+
payload = {"error": {"code": "invalid_params", "message": str(exc), "outcome": "not_dispatched"}}
|
|
47
|
+
failed = True
|
|
48
|
+
return CallToolResult(content=[TextContent(type="text", text=json.dumps(payload, allow_nan=False, separators=(",", ":")))], structuredContent=payload, isError=failed)
|
|
49
|
+
|
|
50
|
+
return server
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def run() -> None:
|
|
54
|
+
server = create_server()
|
|
55
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
56
|
+
await server.run(read_stream, write_stream, server.create_initialization_options())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> None:
|
|
60
|
+
asyncio.run(run())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bitwig-agent
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Thin stdio MCP adapter for the local Bitwig Controller Extension
|
|
5
|
+
Project-URL: Homepage, https://github.com/BANANASJIM/bitwig-agent
|
|
6
|
+
Project-URL: Documentation, https://github.com/BANANASJIM/bitwig-agent/blob/main/INSTALL.md
|
|
7
|
+
Project-URL: Repository, https://github.com/BANANASJIM/bitwig-agent
|
|
8
|
+
Project-URL: Issues, https://github.com/BANANASJIM/bitwig-agent/issues
|
|
9
|
+
Project-URL: Releases, https://github.com/BANANASJIM/bitwig-agent/releases
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: audio,bitwig,mcp,midi
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
23
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: mcp<2,>=1.29.1
|
|
26
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Bitwig Agent
|
|
30
|
+
|
|
31
|
+
An experimental Bitwig Controller Extension with Python MCP and CLI tools. Agents can inspect and control tracks, devices and MIDI clips, and author **The Grid** patches through structured tools. Your agent owns planning; this bridge provides bounded controls and observed results, not automatic GUI fallback.
|
|
32
|
+
|
|
33
|
+
**Tested compatibility:** native evidence is limited to **Linux, Bitwig Studio 6.1.3 / Controller API 25**. macOS, Windows and other Bitwig versions are unverified. Private Grid/modulator access additionally requires the exact mapped host-JAR SHA-256 and fails closed on a mismatch; do not bypass that gate. See [verification and coverage limits](VERIFICATION.md).
|
|
34
|
+
|
|
35
|
+
**Use a disposable project and keep backups.** The extension listens on unauthenticated `127.0.0.1:61172`: any local process able to connect can control Bitwig. Use a trusted local machine; never expose or forward this port to a network. Never blindly retry an uncertain mutation: a timeout may mean the edit happened. [Targeting and completion safety](USAGE.md#target-safely-then-observe-completion) applies to every write.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
You need your own Bitwig Studio installation, Python 3.11+ and [uv](https://docs.astral.sh/uv/getting-started/installation/). The released wheel and extension need **no Git checkout or JDK**. The commands below use GitHub release assets, not a PyPI package.
|
|
40
|
+
|
|
41
|
+
### 1. Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv tool install "https://github.com/BANANASJIM/bitwig-agent/releases/download/v0.1.1/bitwig_agent-0.1.1-py3-none-any.whl"
|
|
45
|
+
bitwig-agent-install
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The installer downloads the same-version `BitwigAgent.bwextension` and `SHA256SUMS`, verifies the extension checksum, and installs it in your Bitwig Extensions directory. Different existing bytes are backed up outside Bitwig's extension scan; identical bytes are left untouched. [Custom locations, offline installation and troubleshooting](INSTALL.md).
|
|
49
|
+
|
|
50
|
+
Prefer this persistent install for an MCP client. [Installation alternatives](INSTALL.md#persistent-uv-tools-and-ephemeral-uvx) cover ephemeral `uvx`, offline assets and the local Arch package recipe. PyPI/AUR publication is separate from GitHub releases; the short index-based commands are not the current installation path.
|
|
51
|
+
|
|
52
|
+
### 2. Enable in Bitwig
|
|
53
|
+
|
|
54
|
+
Open **Settings → Controllers → Add controller** and choose vendor **Bitwig Agent**, model **Local API bridge** (controller name **Bitwig Agent**). No MIDI ports are needed. Enable it, open a disposable project, then check the connection:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
bitwig-agent-rpc status
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A successful status response from the running extension establishes connectivity—not successful editing, audio output or persistence. Connection failures report `outcome: not_dispatched` until the endpoint is available. If the controller is absent, check the install destination and restart Bitwig after installing/replacing the extension.
|
|
61
|
+
|
|
62
|
+
### 3. Connect your MCP client
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
bitwig-agent-install --mcp-config
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Copy the printed JSON into your client's MCP server configuration, following that client's instructions, then reload/restart its MCP connection. This command **only prints configuration**; it does not install/download anything or edit your client settings. The generated configuration launches the installed Python environment by absolute path, avoiding GUI applications' often different `PATH`. The adapter uses **stdio**; the client starts it and connects it to the local extension.
|
|
69
|
+
|
|
70
|
+
[examples/mcp-config.json](examples/mcp-config.json) is a minimal static alternative using the installed `bitwig-agent` command. Prefer generated configuration, especially for GUI clients. See [connection troubleshooting](INSTALL.md#troubleshooting).
|
|
71
|
+
|
|
72
|
+
## Useful checks
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
bitwig-agent-install --version
|
|
76
|
+
bitwig-agent-rpc list
|
|
77
|
+
bitwig-agent-rpc describe notes.edit
|
|
78
|
+
bitwig-agent-rpc tracks.set --help
|
|
79
|
+
bitwig-agent-rpc status
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Version, catalog, schema and help commands work **offline**; they do not demonstrate a Bitwig connection. Only the last command above contacts the extension. Running `bitwig-agent` manually starts a stdio server waiting for an MCP client, not a graphical application or a connection check.
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
85
|
+
|
|
86
|
+
- [Documentation index](docs/README.md): choose an installation, operation, architecture or packaging guide.
|
|
87
|
+
- [Installation](INSTALL.md): persistent uv tools, ephemeral uvx, local Arch package, offline/custom install and troubleshooting.
|
|
88
|
+
- [Usage and safety](USAGE.md): control bounds, targeting, unknown outcomes and the explicit Grid music recipe.
|
|
89
|
+
- [Architecture](docs/architecture.md): Python/Java interfaces, state ownership and why the bridge does not plan.
|
|
90
|
+
- [Protocol](PROTOCOL.md): request/response schemas, limits and side effects.
|
|
91
|
+
- [Verification](VERIFICATION.md): reproducible checks and unresolved native coverage; host-free checks are not native acceptance.
|
|
92
|
+
- [Contributing](CONTRIBUTING.md) and [packaging](docs/packaging.md): source setup, artifacts and release promotion.
|
|
93
|
+
- [Portable agent skill](https://github.com/BANANASJIM/bitwig-agent/tree/main/skills/bitwig-agent): copyable Agent Skills instructions; not permission to operate your DAW.
|
|
94
|
+
- [Security](SECURITY.md): local trust boundary and private vulnerability reporting.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
Project code is [MIT licensed](LICENSE). Gson is Apache-2.0; see [third-party notices](THIRD_PARTY_NOTICE.md) and [its license](third-party/GSON-LICENSE.txt). Bitwig Studio and its proprietary host JAR are separate dependencies and are not bundled.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bitwig_agent/__init__.py,sha256=07KQ7gXfqsG7st-hqBcT9RVGDAH2f5ui1Lio_WnQ4Yg,73
|
|
2
|
+
bitwig_agent/__main__.py,sha256=ZK2phOuhaMi1uRcgrZ5usrKpx3uxc_iBqkd0wr6venQ,125
|
|
3
|
+
bitwig_agent/catalog.py,sha256=tHDseznVAfvq-qv5ad3Q31UgCxKsQvPr2nVQOEsxTeg,27400
|
|
4
|
+
bitwig_agent/cli.py,sha256=F-XhigpQ-D7B-mdupeAggk1J9-yYrJ650Bj-hI_YSHQ,6025
|
|
5
|
+
bitwig_agent/client.py,sha256=duQTww4YgID5zss322DiTj255bBcuje0WLR7CgiCu1M,5177
|
|
6
|
+
bitwig_agent/install.py,sha256=pKP8WaFyrhbzhoAqwy4-_-1_ciBiaYxLooDEGGKAxlg,5063
|
|
7
|
+
bitwig_agent/schema.py,sha256=Jt8fq3RlQsmChGFHM8wKMzDsOiNnLnFvhah3UB3EIIQ,16345
|
|
8
|
+
bitwig_agent/server.py,sha256=wGZww44Ijs7BaK9wBrFLakJB1ku_F0-V4btYCg-YLsg,2833
|
|
9
|
+
bitwig_agent-0.1.1.dist-info/METADATA,sha256=t8Yb4iQMATcm5aOW7QXwVZsm-zpKv7RxVO1SF0mN_x8,6744
|
|
10
|
+
bitwig_agent-0.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
bitwig_agent-0.1.1.dist-info/entry_points.txt,sha256=7P3eP1bMB5NU1cz2MYC1m0HKh1zuoRn06J7fnWEVh9I,148
|
|
12
|
+
bitwig_agent-0.1.1.dist-info/licenses/LICENSE,sha256=6gTlgcTupAGCCELiOc8ZRVT5p0QBoAfo2l1pzd0giso,1067
|
|
13
|
+
bitwig_agent-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BANANASJIM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|