diff --git a/.gitignore b/.gitignore index 37f097cd..4ec120c5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ hs_err_pid* .nyx/ CHANGELOG.md + +# Python +__pycache__/ +*.pyc diff --git a/README.md b/README.md index e5120e2c..bb27ef63 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ The extension enables external AI agents (e.g., IDE-based copilots, standalone A - Transport control (start/stop playback) - Device parameter control for selected devices - Clip and scene launching +- Project editing: create/delete/duplicate tracks; volume (exact dB), pan, mute, solo, arm, color, sends +- Devices: insert Bitwig built-ins by name or plugins by CLAP/VST3 id; copy, move, bypass, delete +- Parameters of any device on any track, by name or page, without moving the user's selection +- Clips: import audio/MIDI files into launcher slots, create clips, write MIDI notes +- Tempo, loop, metronome, record; run any Bitwig menu action; undo/redo +- Device chains (Vocoder modulator/carrier, FX slots, layers), presets and Bitwig's browser, Drum Machine pads, layer mixers +- Device knowledge: every installed Bitwig device and plugin (CLAP, VST3, VST2 from Bitwig's plugin cache), control scans on demand, Bitwig's own device descriptions +- Note expressions and conditions, clip settings, scenes, arranger clips selected by the user, groove, cue mix +- Following the user: an event log of changes (`get_events`, with long-poll) and `watch`, which streams them as MCP notifications +- Listening: meters, playing notes, auditioning notes, sending MIDI + +See [docs/AGENT-GUIDE.md](docs/AGENT-GUIDE.md) for how the tools fit together and [ROADMAP.md](ROADMAP.md) for what Bitwig's API cannot do. ## Requirements @@ -39,6 +51,21 @@ This will generate a `.bwextension` file in the `build/extensions` directory. Once the extension is activated in Bitwig Studio, the MCP server will be available at `http://localhost:61169/mcp`. External AI agents can connect to this endpoint to send commands and receive responses. +## Testing + +```bash +./gradlew test # unit tests +python3 scripts/live_test.py # end-to-end against a running Bitwig with WigAI enabled +python3 scripts/live_test_project.py # project-wide tools, groove, clip management +python3 scripts/live_test_container.py # presets, browser, drum pads, layers +python3 scripts/live_test_notes.py # note expressions, clip settings, scenes, cue mix +python3 scripts/live_test_midi.py # playing notes, MIDI, modulation readout, show +python3 scripts/live_test_events.py # plugin inventory, get_events, watch streaming +``` + +The live tests create, edit and delete their own tracks, so they refuse to run unless an empty +project is open (`live_test.py --force` overrides). Never run them against a song you care about. + ## Development This project is developed using the [BMAD v2 method](https://github.com/bmadcode/BMAD-METHOD) with AI Agents. The files in folders `agents`, `ai` and `docs` used for this development method. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..c53565c3 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,50 @@ +# WigAI API roadmap + +What the Bitwig Controller API (v19) can do that WigAI does not expose yet, ordered by how much it helps an agent produce music. The raw method-by-method list is generated into [docs/api-coverage.md](docs/api-coverage.md) by `scripts/api_coverage.py`; this file is the curated version. + +## Done + +| Area | Tools | +|---|---| +| Read the project | `status`, `list_tracks`, `get_track_details`, `list_devices_on_track`, `get_device_details`, `list_scenes`, `get_clips_in_scene` (upstream) | +| Transport and launching | `transport_start`, `transport_stop`, `launch_clip`, scene launching (upstream), `set_transport`, `stop_track` | +| Tracks and mixer | `create_track`, `delete_track`, `duplicate_track`, `get_track_mixer`, `set_track`, `set_send` | +| Devices | `list_bitwig_devices`, `insert_device`, `copy_device`, `move_device`, `delete_device`, `set_device_enabled`, `set_device_window_open` | +| Chains inside devices | `get_device_chains`, plus `chain` / `chain_device_index` on the device tools (slots such as a Vocoder's Modulator/Carrier, FX slots, layers) | +| Parameters of any device | `get_device_parameters`, `set_device_parameters` (all remote-control pages, by name or page+index) | +| Clips and notes | `insert_file`, `create_clip`, `delete_clip`, `write_notes`, `read_notes`, `set_clip` | +| Listening | `set_engine_active`, `get_levels`, `play_notes` | +| Arrangement | `list_cue_markers`, `go_to_cue_marker`, `set_arrangement` (loop range, time signature), `rename_scene` | +| Actions | `list_actions`, `invoke_action`, `undo`, `redo` | +| Project-wide | `project_state`, `reset_project` (unsolo/unmute/unarm all), `create_scene`, `set_groove`, `set_transport_options` (punch, metronome volume, pre-roll, automation write mode, launcher overdub, launch quantization, post-recording action, rewind/fast-forward/tap tempo) | +| Clip management and note editing | `copy_clip`, `move_clip`, `duplicate_clip`, `record_into_slot`, `edit_notes` (velocity, length, gain, pan, timbre, pressure, micro-pitch, chance, mute, repeats), `transform_clip` (transpose, quantize, duplicate content, launch quantization/mode) | +| Presets and the library | `list_presets` (every .bwpreset on disk, searchable), `preset_file` on `insert_device`, `replace_device` (device, plugin or preset in place), `browser_open` / `browser_state` / `browser_filter` / `browser_commit` / `browser_cancel` (Bitwig's own browser: content types, every filter column, paged results; mode `replace` lists a device's presets) | +| Drum Machine and layers | `list_drum_pads`, `set_drum_pad` (name, volume, pan, mute, solo), `insert_into_drum_pad` (device, plugin, preset, or a sample, which becomes a Sampler), `clear_drum_pads`, `list_layers`, `set_layer`; device lists now show each device's loaded preset | +| Note expressions, clip settings, scenes, arranger clips | `edit_notes` adds release velocity, velocity spread, occurrence, recurrence and repeat curves; `read_notes` reports every non-default expression; `set_clip` adds loop on/off, play start/stop, shuffle, accent, color; `arranger: true` on `read_notes` / `edit_notes` / `set_clip` / `transform_clip` and `write_arranger_notes` work on the arranger clip the user selected; `set_scene`, `delete_scene`, `duplicate_scene`, `copy_scene`, `move_scene`; `set_cue` (cue volume and mix); `set_track` adds group expand/collapse and crossfade side | +| Listening, MIDI and the UI | `get_playing_notes` (notes sounding per track), `send_midi` (CC, pitch bend, program, aftertouch, raw), modulated values in `get_device_parameters`, `show` (panel layout, track, clip in editor, device) | +| Following the user, and plugins | `get_events` (event log of transport, project, track, clip, selection and parameter changes, with long-poll), `watch` (streams changes as MCP progress and log notifications, [fabb/WigAI#3](https://github.com/fabb/WigAI/issues/3)); `list_installed_devices` reads CLAP, VST3 and VST2 plugins from Bitwig's own plugin cache; `vst2_id` on `insert_device` / `replace_device`; server instructions for agents and [docs/AGENT-GUIDE.md](docs/AGENT-GUIDE.md) | +| Device knowledge | `list_installed_devices`, `describe_device` (on-demand control scans with history), `fetch_device_docs`, `catalog_status` | + +## Next + +Everything the Controller API offers that helps an agent make music is now exposed. Left: + +1. **Resource subscriptions.** Expose project state as MCP resources with `resources/updated` notifications once the MCP Java SDK supports subscriptions (0.11 does not); `get_events` and `watch` cover the need meanwhile. +2. **UI automation for what the API cannot do** (routing, arranger placement): would need driving Bitwig's window from outside, which is fragile and takes over the user's screen. Not planned. + +## Not possible with the Controller API + +These need changes in Bitwig itself; tools should tell the user to do them by hand. + +- **Audio and MIDI routing:** track inputs/outputs, sidechain sources, an Audio Receiver's source. `Track.sourceSelector` is read-only. +- **Placing clips on the arranger timeline**, or reading where arranger clips are. +- **Adding or removing modulators** (LFOs, envelopes) and modulation routings. Their effect can be read: `get_device_parameters` reports a control's modulated value. +- **Writing automation points.** Only by recording parameter changes with automation write enabled. +- **Parameters that are not on a remote-control page** ([fabb/WigAI#15](https://github.com/fabb/WigAI/issues/15)). +- **Rendering, bouncing or exporting audio**, other than through application actions where Bitwig offers one. +- **Moving or copying tracks.** Bitwig 6.1.3 accepts `InsertionPoint.moveTracks/copyTracks` from an extension and silently does nothing, on the request thread and on the control thread alike (tested 2026-10-17). `duplicate_track` does work for copies. +- **Opening a project from disk.** Only cycling through projects that are already open. +- **Adding, removing or duplicating layers, and emptying a drum pad.** `DeviceLayer.duplicate()` / `duplicateObject()` / `deleteObject()`, `DrumPad.deleteObject()` and `host.duplicateObjects(layer)` are silently ignored in Bitwig 6.1.3. **`host.deleteObjects(drumPad)` crashes Bitwig** (ClassCastException on its GUI thread), taking every open project with it; never call it. Delete the devices on a pad or layer with `delete_device` and chain `layer:N` instead. +- **Capturing playing clips into a scene.** `Project.createSceneFromPlayingLauncherClips()` and the "Create Scene From Playing Launcher Clips" action do nothing when called from an extension, even with clips playing (tested 2026-10-17). +- **Selecting an arranger clip.** The arranger cursor clip follows the user's selection only; tools with `arranger: true` work on whatever clip the user clicked. +- **Reading the device of a packaged preset from disk.** Presets from installed packages are encrypted; `browser_open` with mode `replace` shows which presets belong to a device. diff --git a/_bmad-output/planning-artifacts/api-reference.md b/_bmad-output/planning-artifacts/api-reference.md index 30fb7013..8ab789b6 100644 --- a/_bmad-output/planning-artifacts/api-reference.md +++ b/_bmad-output/planning-artifacts/api-reference.md @@ -111,6 +111,7 @@ Communication is message-based, typically using JSON-RPC or a similar structured - All actions and responses are logged via the Logger service. #### `project_getName` +* **Not implemented as a separate tool**: the project name is returned by `status` (`project_name`). Kept here for history. * **Description**: Get the name of the current Bitwig Studio project. * **Parameters**: None * **Returns**: @@ -721,6 +722,846 @@ Rules: - `DEVICE_NOT_SELECTED` - `BITWIG_API_ERROR` +### Project Editing Commands + +Tools from [Epic 9](./prd/epic-9.md). Shared rules: + +* **Track targeting:** `track_index` (0-based, as in `list_tracks`) or `track_name` (exact, case-sensitive). Device-scoped tools add `device_index` (as in `list_devices_on_track`). +* **Asynchrony:** mutating tools wait (up to 2 s) until Bitwig shows the change, then return the resulting state. A change that never appears returns `BITWIG_TIMEOUT`. +* **Common errors:** `TRACK_NOT_FOUND`, `DEVICE_NOT_FOUND`, `MISSING_REQUIRED_PARAMETER`, `INVALID_PARAMETER` (bad argument type or value), `INVALID_RANGE`, `BITWIG_TIMEOUT`, `OPERATION_FAILED`. + +#### `create_track` +* **Description**: Create an audio, instrument or effect track. +* **Parameters**: + ```json + { + "type": "instrument", // required: "audio" | "instrument" | "effect" + "position": 3, // optional: index among regular tracks (or among effect tracks for "effect"); default end + "name": "Vocoder Chords" // optional + } + ``` +* **Returns**: + ```json + { "status": "success", "data": { "type": "instrument", "track_index": 3, "name": "Vocoder Chords" } } + ``` +* **Notes**: The new track's type is checked before it is renamed; if it cannot be located, `OPERATION_FAILED` is returned and nothing is renamed. + +#### `delete_track` +* **Parameters**: track targeting. +* **Returns**: `{ "deleted_track": "Audio 2", "track_index": 1 }` +* **Errors**: `INVALID_PARAMETER` for the master track. + +#### `duplicate_track` +* **Parameters**: track targeting. +* **Returns**: `{ "duplicated_track": "Lead", "new_track_index": 2 }` +* **Errors**: `INVALID_PARAMETER` for the master track. + +#### `get_track_mixer` +* **Parameters**: track targeting. +* **Returns** (the same object is returned by `set_track` and `set_send`): + ```json + { + "status": "success", + "data": { + "track_index": 1, + "name": "Lead Vox", + "volume": 0.5407, // normalized fader position + "volume_display": "-10.0 dB", + "pan": 0.0, // -1 (left) .. 1 (right) + "pan_display": "0.00 %", + "mute": false, + "solo": false, + "arm": false, + "activated": true, + "monitor_mode": "AUTO", + "is_master": false, + "sends": [ { "index": 0, "name": "FX 1", "value": 0.0, "display": "-Inf dB", "enabled": false } ] + } + } + ``` + +#### `set_track` +* **Description**: Change track settings; only given fields change. +* **Parameters**: + ```json + { + "track_name": "Lead Vox", + "name": "Lead", // optional + "volume": 0.7, // optional: normalized 0..1 + "volume_db": -3.0, // optional: matched against Bitwig's dB display (max 6) + "pan": -0.25, // optional: -1..1 + "mute": false, "solo": false, "arm": true, "activated": true, // optional + "monitor_mode": "AUTO", // optional: "ON" | "OFF" | "AUTO" + "color": "#3366CC" // optional + } + ``` +* **Notes**: `volume_db` binary-searches the fader against the displayed value and keeps the closest position; the display is rounded to 0.1 dB. + +#### `set_send` +* **Parameters**: track targeting, `send_index` (required), `value` (0..1, optional), `enabled` (optional). +* **Errors**: `INVALID_RANGE` if the send does not exist; `RESOURCE_UNAVAILABLE` if the track has no sends. + +#### `list_bitwig_devices` +* **Description**: Names of Bitwig's built-in devices accepted by `insert_device`. +* **Returns**: `["Amp", "Audio Receiver", ..., "EQ+", ..., "Vocoder", ...]` + +#### `insert_device` +* **Parameters**: + ```json + { + "track_name": "Lead", + "bitwig_device": "EQ+", // one of: bitwig_device (case-insensitive name), + "clap_id": null, // clap_id (e.g. "org.surge-synth-team.surge-xt"), + "vst3_id": null, // vst3_id (VST3 class id) + "position": "end", // optional: "start" | "end" (default "end") + "after_device_index": 0 // optional: insert after this device instead + } + ``` +* **Returns**: + ```json + { "status": "success", "data": { "inserted": true, "devices": [ { "index": 0, "name": "Compressor", "enabled": true }, { "index": 1, "name": "EQ+", "enabled": true } ] } } + ``` +* **Errors**: `DEVICE_NOT_FOUND` (unknown built-in name), `MISSING_REQUIRED_PARAMETER` (no id given). + +#### `copy_device` / `move_device` +* **Parameters**: + ```json + { + "from_track_name": "Lead", "from_device_index": 1, // or from_track_index + "to_track_name": "Backing", // or to_track_index + "position": "end", // optional, or after_device_index + "after_device_index": null + } + ``` +* **Returns**: `{ "target_track_devices": [ { "index": 0, "name": "EQ+", "enabled": true } ] }` +* **Notes**: Copies keep all device settings. + +#### `delete_device` +* **Parameters**: track targeting, `device_index`. +* **Returns**: `{ "deleted_device": "Reverb", "devices": [ ... ] }` + +#### `set_device_enabled` +* **Parameters**: track targeting, `device_index`, `enabled` (`false` = bypassed). +* **Returns**: `{ "device": "Vocoder", "enabled": true }` + +#### `set_device_window_open` +* **Parameters**: track targeting, `device_index`, `open`. +* **Returns**: `{ "device": "Surge XT", "window_open": true }` + +#### `get_device_parameters` +* **Description**: Read the remote-control parameters of any device without changing the user's selection. +* **Parameters**: track targeting, `device_index`, `page` (optional; default all pages). +* **Returns**: + ```json + { + "status": "success", + "data": { + "track_index": 2, + "device_index": 0, + "device": "Reverb", + "page_names": ["Main", "Tone", "Common"], + "pages": [ + { + "page": 0, + "page_name": "Main", + "parameters": [ + { "index": 0, "name": "Size", "value": 0.495, "display": "99.3 %" }, + { "index": 1, "name": "Pre-delay", "value": 0.04, "display": "4.00 ms" } + ] + } + ] + } + } + ``` + +#### `set_device_parameters` +* **Description**: Set parameters on any device by name or by page+index. Values are normalized; check `display` for units. +* **Parameters**: + ```json + { + "track_index": 2, + "device_index": 0, + "parameters": [ + { "name": "Mix", "value": 0.35 }, // name: case-insensitive, searched across pages + { "page": 0, "index": 1, "value": 0.9 } // or page + index (0..7) + ] + } + ``` +* **Returns**: + ```json + { + "status": "success", + "data": { + "device": "Reverb", + "results": [ + { "request": "Mix", "applied": true, "status": "success", "page": 0, "name": "Mix", "value": 0.35, "display": "35.0 %" }, + { "request": "page 9 index 0", "status": "error", "message": "Parameter not found" } + ] + } + } + ``` +* **Notes**: Each item succeeds or fails on its own. `applied` is `false` if Bitwig did not report the new value within the timeout. + +#### `insert_file` +* **Parameters**: track targeting, `path` (absolute, required), `slot_index` (optional). +* **Returns**: with `slot_index`: `{ "track_index": 0, "slot_index": 1, "inserted": true }`; without: `{ "new_track_index": 1, "inserted": true }` (Bitwig inserts the file as a new track after the given one). +* **Errors**: `INVALID_PARAMETER` if the file does not exist. +* **Notes**: The Controller API cannot place clips on the arranger timeline. Bitwig splits a MIDI file with several tracks or channels into one new track each instead of filling the slot; the result then reports `new_tracks_created` and `inserted: false`. + +#### `create_clip` +* **Parameters**: track targeting, `slot_index`, `length_beats` (integer ≥ 1). +* **Returns**: `{ "track_index": 0, "slot_index": 0, "length_beats": 4, "created": true }` +* **Errors**: `INVALID_PARAMETER` if the slot already holds a clip. + +#### `delete_clip` +* **Parameters**: track targeting, `slot_index`. +* **Returns**: `{ "track_index": 0, "slot_index": 0, "deleted": true }` + +#### `write_notes` +* **Parameters**: + ```json + { + "track_index": 0, + "slot_index": 0, + "clear_first": true, // optional, default false + "notes": [ + { "start": 0, "pitch": 60, "velocity": 100, "duration": 1.0 }, // beats from clip start; velocity optional (100) + { "start": 1.5, "pitch": 64, "duration": 0.5 } + ] + } + ``` +* **Returns**: `{ "track_index": 0, "slot_index": 0, "notes_written": 2 }` +* **Errors**: `CLIP_NOT_FOUND` if the slot is empty. +* **Notes**: Start times snap to a 1/64-beat grid. + +#### `set_transport` +* **Parameters** (all optional): `tempo_bpm`, `position_beats`, `loop`, `metronome`, `record`, `overdub`. +* **Returns**: `{ "tempo": "110.00 BPM", "position_beats": 0.0, "loop": false, "metronome": false, "record": false, "overdub": true }` + +#### `list_actions` +* **Parameters**: `filter` (optional, case-insensitive substring of id, name or category). +* **Returns**: `[ { "id": "toggle_metronome", "name": "Toggle Metronome", "category": "Project" } ]` + +#### `invoke_action` +* **Parameters**: `id` (required, from `list_actions`). +* **Returns**: `{ "invoked": "toggle_metronome", "name": "Toggle Metronome" }` +* **Errors**: `INVALID_PARAMETER` for an unknown id. + +#### `undo` / `redo` +* **Parameters**: none. +* **Returns**: `{ "action": "undo" }` + +### Commands from Epics 10-15 + +Tools from Epics 10-15. The shared rules of the [Project Editing Commands](#project-editing-commands) apply. Every tool returns `{ "status": "success", "data": ... }`; the live tests in `scripts/live_test_*.py` show real responses for each tool. + +#### Additions to Epic 9 tools + +* Device tools (`insert_device`, `copy_device`/`move_device`, `delete_device`, `set_device_enabled`, `get_device_parameters`, `set_device_parameters`) take `chain` and `chain_device_index` (copy/move: `from_chain`, `from_chain_device_index`, `to_device_index`, `to_chain`) to work inside a device's chains ([Story 10.1](./stories/10.1.story.md)). +* `insert_device` also takes `vst2_id` and `preset_file`. +* `get_device_parameters` adds `modulated_value` / `modulated_display` to controls a modulator is moving. +* `set_track` adds `group_expanded` and `crossfade_mode`; `get_track_mixer` reports them. +* Device lists report each device's loaded `preset`. + +### Epic 10: Device Chains, Presets and Containers + +#### `get_device_chains` +* **Description**: List the chains inside a device and the devices in each: slots such as a Vocoder's 'Modulator' and 'Carrier' or an FX device's 'FX', and layers of FX/Instrument Layer devices. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0 // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + } + ``` + +#### `list_presets` +* **Description**: Search Bitwig preset files on disk (installed packages and the user library). Every word of query must match the name, collection, device or category. Load one with insert_device or replace_device (preset_file). Packaged presets do not reveal their device; browser_open with mode 'replace' lists the presets of a device. +* **Parameters**: + ```json + { + "query": "...", // optional; string + "limit": 1, // optional; integer >= 1 + "rescan": true // optional; boolean; Re-read the disk (after installing packages) + } + ``` + +#### `replace_device` +* **Description**: Replace a device (or a device inside a chain) in place with a Bitwig device, a plugin, or a preset file. Loading a preset onto an existing device is a replace with preset_file. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "chain": "...", // optional; string; A chain inside the device, e.g. 'Modulator' or 'Carrier' (Vocoder), 'FX' (FX devices), or 'layer:0' / 'layer:Name' (layer devices). See get_device_chains. + "chain_device_index": 0, // optional; integer >= 0; Device index within that chain (required with chain) + "bitwig_device": "...", // optional; string + "clap_id": "...", // optional; string + "vst3_id": "...", // optional; string + "vst2_id": 0, // optional; integer + "preset_file": "..." // optional; string; Absolute path of a .bwpreset (or other device file) + } + ``` + +#### `browser_open` +* **Description**: Open Bitwig's browser to load something at a place: mode 'replace' (a device; Bitwig shows that device's presets), 'after'/'before' (a device), 'start'/'end' (the track's chain). It shows in Bitwig's window until browser_commit or browser_cancel. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // optional; integer >= 0 + "mode": "replace", // optional; "replace" | "after" | "before" | "start" | "end" + "content_type": "..." // optional; string; e.g. Devices, Presets, Samples (see content_types) + } + ``` + +#### `browser_state` +* **Description**: The open browser: content type, the selected item of each filter column, and a page of results (64 per page). list_column also returns every item of that column. +* **Parameters**: + ```json + { + "list_column": "collection", // optional; "collection" | "location" | "device" | "category" | "tag" | "device_type" | "file_type" | "creator" + "results_page": 0 // optional; integer >= 0 + } + ``` + +#### `browser_filter` +* **Description**: Narrow the open browser: content_type, and filters as {column: item}, e.g. {"category": "Bass", "creator": "Bitwig"}. Columns: collection, location, device, category, tag, device_type, file_type, creator. An 'Any ...' item clears a column. +* **Parameters**: + ```json + { + "content_type": "...", // optional; string + "filters": { "category": "Reverb" } // optional; object + } + ``` + +#### `browser_commit` +* **Description**: Load a result of the open browser, by name or by index, and close it. +* **Parameters**: + ```json + { + "result": "...", // optional; string + "result_index": 0 // optional; integer >= 0 + } + ``` + +#### `browser_cancel` +* **Description**: Close the browser without loading anything. +* **Parameters**: none. + +#### `list_drum_pads` +* **Description**: The pads of a Drum Machine by MIDI key: name, volume, pan, mute, solo and the devices on each pad. Empty pads are left out unless include_empty. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "include_empty": true // optional; boolean + } + ``` + +#### `set_drum_pad` +* **Description**: Rename a Drum Machine pad or change its volume, pan, mute, solo or activation. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "pad": 0, // required; integer 0..127; MIDI key of the pad (36 = C1, the usual kick) + "name": "...", // optional; string + "volume": 0.5, // optional; number 0..1 + "volume_db": 1.0, // optional; number <= 6 + "pan": 0.5, // optional; number -1..1 + "mute": true, // optional; boolean + "solo": true, // optional; boolean + "activated": true // optional; boolean + } + ``` + +#### `insert_into_drum_pad` +* **Description**: Add a device to a Drum Machine pad (after what is there): a Bitwig device, a plugin, a preset file, or a sample file (Bitwig creates a Sampler for it). +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "pad": 0, // required; integer 0..127; MIDI key of the pad (36 = C1, the usual kick) + "bitwig_device": "...", // optional; string + "clap_id": "...", // optional; string + "vst3_id": "...", // optional; string + "file": "..." // optional; string; Absolute path of a sample or preset + } + ``` + +#### `clear_drum_pads` +* **Description**: Unmute and/or unsolo every pad of a Drum Machine. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "mutes": true, // optional; boolean + "solos": true // optional; boolean + } + ``` + +#### `list_layers` +* **Description**: The layers of a layer device (Instrument Layer, FX Layer, ...) with their mixer settings and devices. Devices inside a layer are edited with chain 'layer:N' on the device tools. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0 // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + } + ``` + +#### `set_layer` +* **Description**: Rename a layer or change its volume, pan, mute, solo or activation. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "device_index": 0, // required; integer >= 0; 0-based device index on the track (see list_devices_on_track) + "layer": 0, // required; integer >= 0 + "name": "...", // optional; string + "volume": 0.5, // optional; number 0..1 + "volume_db": 1.0, // optional; number <= 6 + "pan": 0.5, // optional; number -1..1 + "mute": true, // optional; boolean + "solo": true, // optional; boolean + "activated": true // optional; boolean + } + ``` + +### Epic 11: Device Knowledge + +#### `list_installed_devices` +* **Description**: Find devices and plugins installed on this machine (Bitwig built-ins, CLAP plugins, LV2 plugins usable through Carla). The query is matched against names, vendors, categories, descriptions and the device guide's 'use for' text, best matches first; e.g. 'vocal compressor', 'reverb', 'pitch'. Use it to pick candidates before recommending or inserting a device. +* **Parameters**: + ```json + { + "query": "...", // optional; string + "kind": "bitwig" // optional; "bitwig" | "clap" | "lv2" + } + ``` + +#### `describe_device` +* **Description**: Everything known about one device: what it is for and how to use it (device guide), its plugin description, its chains, and every control's real range, units and options. Controls are measured the first time a device is described and cached (with a scan history); pass track/device_index to measure a device already in the project (its values are restored), or just a name to measure it on a temporary track. Measuring is skipped while the transport plays. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "name": "...", // optional; string; Bitwig device name, plugin name or CLAP id + "device_index": 0, // optional; integer >= 0 + "chain": "...", // optional; string + "chain_device_index": 0, // optional; integer >= 0 + "scan": true, // optional; boolean; Measure controls if not cached (default true) + "rescan": true // optional; boolean; Measure again even if cached + } + ``` + +#### `catalog_status` +* **Description**: Device catalog coverage and freshness: entries and scans per kind, the most recent scans with what changed, scans that failed, and scans made on an older Bitwig version. +* **Parameters**: none. + +#### `fetch_device_docs` +* **Description**: Fetch Bitwig's own description of every built-in device from Bitwig's online user guide into the local catalog, so describe_device can include it (field bitwig_docs). Run once, and again after a Bitwig update; needs internet access. +* **Parameters**: none. + +### Epic 12: Listening and Auditioning + +#### `set_engine_active` +* **Description**: Activate or deactivate Bitwig's audio engine for the current project. Only one open project has the engine; without it nothing plays, meters or auditions (status shows audio_engine_active). +* **Parameters**: + ```json + { + "active": true // required; boolean + } + ``` + +#### `get_levels` +* **Description**: Current peak and RMS meter readings for every track and the master (0-1 of Bitwig's meter scale, not dB). Only meaningful while audio is playing. +* **Parameters**: none. + +#### `get_playing_notes` +* **Description**: Which notes are sounding right now on each track (from clips, the user's input or play_notes). +* **Parameters**: none. + +#### `play_notes` +* **Description**: Play notes into a track's instrument right now, without a clip, to audition a sound or chord. Times are in milliseconds from the call. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "notes": [ { "pitch": 0, "velocity": 1, "start_ms": 0, "duration_ms": 1 } ] // required; array + } + ``` + +#### `send_midi` +* **Description**: Send a MIDI message into a track's instrument: cc (number, value 0-127), pitch_bend (value -8192..8191, 0 = center), program (number), aftertouch (value), or raw (status 0x80-0xEF, number, value). channel 0-15, default 0. Use play_notes for notes. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "type": "cc", // required; "cc" | "pitch_bend" | "program" | "aftertouch" | "raw" + "channel": 0, // optional; integer 0..15 + "number": 0, // optional; integer 0..127 + "value": 0, // optional; integer + "status": 0 // optional; integer + } + ``` + +#### `show` +* **Description**: Bring something into view in Bitwig's window for the user: a panel layout (ARRANGE, MIX, EDIT), a track, a launcher clip in the editor (slot_index), or a device (device_index). Changes the user's selection. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "panel_layout": "ARRANGE", // optional; "ARRANGE" | "MIX" | "EDIT" + "slot_index": 0, // optional; integer >= 0 + "device_index": 0 // optional; integer >= 0 + } + ``` + +### Epic 13: Arrangement, Scenes, Clips and Notes + +#### `list_cue_markers` +* **Description**: List arranger cue markers with their positions in beats. +* **Parameters**: none. + +#### `go_to_cue_marker` +* **Description**: Jump playback to a cue marker by index or name. +* **Parameters**: + ```json + { + "index": 0, // optional; integer >= 0 + "name": "...", // optional; string + "quantized": true // optional; boolean; Wait for the launch quantization (default false) + } + ``` + +#### `set_arrangement` +* **Description**: Set the arranger loop range (in beats) and/or the time signature, e.g. '6/8'. +* **Parameters**: + ```json + { + "loop_start_beats": 1.0, // optional; number >= 0 + "loop_length_beats": 1.0, // optional; number + "time_signature": "..." // optional; string + } + ``` + +#### `stop_track` +* **Description**: Stop a track's launcher clip, or return the track to arrangement playback. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "return_to_arrangement": true // optional; boolean + } + ``` + +#### `create_scene` +* **Description**: Add a new empty scene. +* **Parameters**: none. + +#### `rename_scene` +* **Description**: Rename a scene. +* **Parameters**: + ```json + { + "scene_index": 0, // required; integer >= 0 + "name": "..." // required; string + } + ``` + +#### `set_scene` +* **Description**: Rename and/or color a scene. +* **Parameters**: + ```json + { + "scene_index": 0, // required; integer >= 0 + "name": "...", // optional; string + "color": "..." // optional; string; #RRGGBB + } + ``` + +#### `duplicate_scene` +* **Description**: Duplicate a scene and its clips; the copy goes below it. +* **Parameters**: + ```json + { + "scene_index": 0 // required; integer >= 0 + } + ``` + +#### `copy_scene` +* **Description**: Copy every clip of a scene onto another scene's row. +* **Parameters**: + ```json + { + "from_scene": 0, // required; integer >= 0 + "to_scene": 0 // required; integer >= 0 + } + ``` + +#### `move_scene` +* **Description**: Move every clip of a scene onto another scene's row. +* **Parameters**: + ```json + { + "from_scene": 0, // required; integer >= 0 + "to_scene": 0 // required; integer >= 0 + } + ``` + +#### `delete_scene` +* **Description**: Delete a scene and its clips. +* **Parameters**: + ```json + { + "scene_index": 0 // required; integer >= 0 + } + ``` + +#### `copy_clip` +* **Description**: Copy a launcher clip to an empty slot (any track). +* **Parameters**: + ```json + { + "from_track_index": 0, // optional; integer >= 0 + "from_track_name": "...", // optional; string + "from_slot": 0, // required; integer >= 0 + "to_track_index": 0, // optional; integer >= 0 + "to_track_name": "...", // optional; string + "to_slot": 0 // required; integer >= 0 + } + ``` + +#### `move_clip` +* **Description**: Move a launcher clip to an empty slot (any track). +* **Parameters**: + ```json + { + "from_track_index": 0, // optional; integer >= 0 + "from_track_name": "...", // optional; string + "from_slot": 0, // required; integer >= 0 + "to_track_index": 0, // optional; integer >= 0 + "to_track_name": "...", // optional; string + "to_slot": 0 // required; integer >= 0 + } + ``` + +#### `duplicate_clip` +* **Description**: Duplicate a launcher clip into the next free slot. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "slot_index": 0 // required; integer >= 0 + } + ``` + +#### `record_into_slot` +* **Description**: Start recording into a launcher slot (the track must be armed). +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "slot_index": 0 // required; integer >= 0 + } + ``` + +#### `set_clip` +* **Description**: Clip settings for a launcher clip (or the arranger's selected clip with arranger: true): name, loop on/off, loop start and length, play start and stop (beats), shuffle on/off, accent (0-1, 0.5 = none), color. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "arranger": true, // optional; boolean; Use the clip selected in Bitwig's arranger instead of a launcher slot + "slot_index": 0, // optional; integer >= 0 + "name": "...", // optional; string + "loop_enabled": true, // optional; boolean + "loop_start_beats": 1.0, // optional; number >= 0 + "loop_length_beats": 1.0, // optional; number + "play_start_beats": 1.0, // optional; number >= 0 + "play_stop_beats": 1.0, // optional; number >= 0 + "shuffle": true, // optional; boolean + "accent": 0.5, // optional; number 0..1 + "color": "..." // optional; string; #RRGGBB + } + ``` + +#### `transform_clip` +* **Description**: Whole-clip operations on a launcher clip (or the arranger's selected clip with arranger: true): transpose (semitones), quantize (0-1 strength), duplicate its content, set launch quantization and launch mode (launcher only; Bitwig enum ids, the response shows the current ones). +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "slot_index": 0, // optional; integer >= 0 + "arranger": true, // optional; boolean; Use the clip selected in Bitwig's arranger instead of a launcher slot + "transpose": 0, // optional; integer + "quantize": 0.5, // optional; number 0..1 + "duplicate_content": true, // optional; boolean + "launch_quantization": "...", // optional; string + "launch_mode": "..." // optional; string + } + ``` + +#### `read_notes` +* **Description**: Read the MIDI notes of a launcher clip, or of the arranger's selected clip with arranger: true (beats from the clip start), with every expression and condition that is not at its default, and the clip settings. +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "slot_index": 0, // optional; integer >= 0 + "arranger": true // optional; boolean; Use the clip selected in Bitwig's arranger instead of a launcher slot + } + ``` + +#### `edit_notes` +* **Description**: Change notes in a launcher clip (or the arranger's selected clip with arranger: true): all of them, or those matching a pitch and/or a start range in beats. velocity/pressure/chance/release_velocity/velocity_spread 0-1, duration in beats, pan and timbre -1..1, gain 0-1, transpose in semitones (micro-pitch, fractional allowed), muted, repeats (count, 0 = off) with repeat_curve and repeat_velocity_curve (-1..1) and repeat_velocity_end (-1..1). occurrence: play only on the first loop, not the first, when the previous note played, etc. ('off' clears it). recurrence: play on selected loop cycles, recurrence_length cycles (1-8, 0 = off) with recurrence_mask bits (bit 0 = first cycle). +* **Parameters**: + ```json + { + "track_index": 0, // optional; integer >= 0; 0-based track index (see list_tracks) + "track_name": "...", // optional; string; Exact track name (alternative to track_index) + "slot_index": 0, // optional; integer >= 0 + "arranger": true, // optional; boolean; Use the clip selected in Bitwig's arranger instead of a launcher slot + "release_velocity": 0.5, // optional; number 0..1 + "velocity_spread": 0.5, // optional; number 0..1 + "occurrence": "off", // optional; "off" | "always" | "first" | "not_first" | "prev" | "not_prev" | "prev_channel" | "not_prev_channel" + "recurrence_length": 0, // optional; integer 0..8 + "recurrence_mask": 0, // optional; integer 0..255 + "repeat_curve": 0.5, // optional; number -1..1 + "repeat_velocity_end": 0.5, // optional; number -1..1 + "repeat_velocity_curve": 0.5, // optional; number -1..1 + "pitch": 0, // optional; integer 0..127 + "from_beats": 1.0, // optional; number >= 0 + "to_beats": 1.0, // optional; number >= 0 + "velocity": 0.5, // optional; number 0..1 + "duration": 1.0, // optional; number + "gain": 0.5, // optional; number 0..1 + "pan": 0.5, // optional; number -1..1 + "timbre": 0.5, // optional; number -1..1 + "pressure": 0.5, // optional; number 0..1 + "transpose": 1.0, // optional; number + "chance": 0.5, // optional; number 0..1 + "muted": true, // optional; boolean + "repeats": 0 // optional; integer >= 0 + } + ``` + +#### `write_arranger_notes` +* **Description**: Write notes into the clip selected in Bitwig's arranger (the API cannot place or select arranger clips; the user selects one). Same note format as write_notes. +* **Parameters**: + ```json + { + "clear_first": true, // optional; boolean + "notes": [ { "start": 1.0, "pitch": 0, "velocity": 1, "duration": 1.0 } ] // required; array + } + ``` + +### Epic 14: Project-wide Control + +#### `project_state` +* **Description**: Whether any track is soloed, muted or armed, and whether the project has unsaved changes. +* **Parameters**: none. + +#### `reset_project` +* **Description**: Clear every solo, mute and/or arm in the project. A forgotten solo silences all other tracks. +* **Parameters**: + ```json + { + "unsolo": true, // optional; boolean + "unmute": true, // optional; boolean + "unarm": true // optional; boolean + } + ``` + +#### `set_groove` +* **Description**: Set the global groove. Values are normalized 0-1; the response shows Bitwig's displayed values. +* **Parameters**: + ```json + { + "enabled": true, // optional; boolean + "shuffle_amount": 0.5, // optional; number 0..1 + "shuffle_rate": 0.5, // optional; number 0..1 + "accent_amount": 0.5, // optional; number 0..1 + "accent_rate": 0.5, // optional; number 0..1 + "accent_phase": 0.5 // optional; number 0..1 + } + ``` + +#### `set_transport_options` +* **Description**: Transport settings beyond play/stop: punch in/out, metronome volume (0-1), pre-roll, automation write mode, launcher overdub, default launch quantization, post-recording action; or run a command (rewind, fast_forward, tap_tempo, continue, record). Enum values are Bitwig's ids; the response shows the current ones. +* **Parameters**: + ```json + { + "punch_in": true, // optional; boolean + "punch_out": true, // optional; boolean + "metronome_volume": 0.5, // optional; number 0..1 + "pre_roll": "...", // optional; string + "automation_write_mode": "...", // optional; string + "launcher_overdub": true, // optional; boolean + "default_launch_quantization": "...", // optional; string + "post_recording_action": "...", // optional; string + "command": "rewind" // optional; "rewind" | "fast_forward" | "tap_tempo" | "continue" | "record" + } + ``` + +#### `set_cue` +* **Description**: Set the cue (headphone) bus volume and its cue/master mix, both 0-1. +* **Parameters**: + ```json + { + "volume": 0.5, // optional; number 0..1 + "mix": 0.5 // optional; number 0..1 + } + ``` + +### Epic 15: Following the User + +#### `get_events` +* **Description**: Changes in Bitwig since since_seq: transport (play, record, loop, tempo), project (switch, engine), track (name, mute, solo, arm, volume, pan, added/removed), clip (launcher playback state, clips created/deleted), selection (the user's selected track, device, preset) and parameter (the selected device's knobs as the user turns them). Call without since_seq to get a starting point, then pass next_seq back. wait_ms waits up to that long for the next change (long-poll). Rapid changes to the same thing are merged into the latest value. +* **Parameters**: + ```json + { + "since_seq": 0, // optional; integer >= 0 + "wait_ms": 0, // optional; integer 0..30000 + "types": ["transport"] // optional; array; Only these kinds of change (default all) + } + ``` + +#### `watch` +* **Description**: Watch Bitwig for duration_ms (up to 60000) and stream each change as it happens: as an MCP progress notification (when the call carries a progress token) and as a log message (logger 'wigai.events'). Returns every change seen, so clients that do not show notifications still get them. +* **Parameters**: + ```json + { + "duration_ms": 100, // optional; integer 100..60000 + "types": ["transport"] // optional; array; Only these kinds of change (default all) + } + ``` + ### Error Handling All MCP tools use a standardized response envelope. diff --git a/_bmad-output/planning-artifacts/prd/epic-10.md b/_bmad-output/planning-artifacts/prd/epic-10.md new file mode 100644 index 00000000..720c3287 --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-10.md @@ -0,0 +1,31 @@ +# Epic 10: Device Chains, Presets and Containers + +## 1. Goal + +Let an agent work inside container devices and with Bitwig's sound library: the chains inside a device (a Vocoder's Modulator and Carrier, FX slots, layers), presets on disk and in Bitwig's browser, Drum Machine pads and the mixers of layer devices. + +## 2. Background + +Epic 9 edits devices on a track's own chain. Most interesting sounds live one level down: a Vocoder needs a carrier in its Carrier chain, a Drum Machine holds a device chain per pad, and Instrument/FX Layer mix several chains. Agents also need to start from presets rather than init patches. The Controller API exposes device slots (`CursorDeviceSlot`), layer and drum pad banks (which are `Channel`s), insertion points with `insertFile`, and the `PopupBrowser`. + +Out of scope: Adding, duplicating or deleting layers and emptying a drum pad: `DeviceLayer.duplicateObject()`/`deleteObject()` and `DrumPad.deleteObject()` are ignored from an extension in Bitwig 6.1.3, and `host.deleteObjects(drumPad)` crashes Bitwig (ClassCastException on its GUI thread), so the tools never call it. Packaged presets are encrypted, so their device is not readable from disk. + +## 3. User Stories + +* As an AI agent, I want to read and edit the chains inside a device (slots and layers), so I can build a vocoder or a layered sound. +* As an AI agent, I want to load presets and search Bitwig's library, so I can start from a designed sound. +* As an AI agent, I want to set up Drum Machine pads and balance the layers of a layer device. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [10.1](../stories/10.1.story.md) Chains Inside Devices | `get_device_chains` | +| [10.2](../stories/10.2.story.md) Presets and Bitwig's Browser | `list_presets`, `replace_device`, `browser_open`, `browser_state`, `browser_filter`, `browser_commit`, `browser_cancel` | +| [10.3](../stories/10.3.story.md) Drum Machine Pads and Layer Mixers | `list_drum_pads`, `set_drum_pad`, `insert_into_drum_pad`, `clear_drum_pads`, `list_layers`, `set_layer` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-10-device-chains-presets-and-containers). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-11.md b/_bmad-output/planning-artifacts/prd/epic-11.md new file mode 100644 index 00000000..dca944a1 --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-11.md @@ -0,0 +1,31 @@ +# Epic 11: Device Knowledge + +## 1. Goal + +Give agents the facts they need to choose devices: everything installed, what each control does, and Bitwig's own description of each built-in device, without editorializing. + +## 2. Background + +`list_bitwig_devices` (Epic 9) only names devices. To recommend a device an agent needs the full inventory (built-ins and plugins), the controls with their real ranges, and a description. Bitwig keeps device metadata in its library and plugin metadata in its own cache, and the user guide describes every built-in device. + +Out of scope: Recommendations themselves: the catalog stores facts; optional personal notes can sit next to it in `device-guide.json` and are returned as `user_notes`. + +## 3. User Stories + +* As an AI agent, I want to search everything installed, built-in and plugin, so I know what I can use. +* As an AI agent, I want to know what each control of a device does, measured rather than guessed. +* As an AI agent, I want to read Bitwig's own description of each device. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [11.1](../stories/11.1.story.md) Installed Devices and Plugins | `list_installed_devices` | +| [11.2](../stories/11.2.story.md) Describe a Device, Scanning on Demand | `describe_device`, `catalog_status` | +| [11.3](../stories/11.3.story.md) Bitwig's Device Descriptions | `fetch_device_docs` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-11-device-knowledge). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-12.md b/_bmad-output/planning-artifacts/prd/epic-12.md new file mode 100644 index 00000000..4d45dfa9 --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-12.md @@ -0,0 +1,31 @@ +# Epic 12: Listening and Auditioning + +## 1. Goal + +Let an agent hear (by meter) what it changed, audition sounds without writing clips, and show the user what it is working on. + +## 2. Background + +After an edit an agent cannot tell whether anything sounds. Bitwig offers VU meters, the notes playing on each channel, direct note and MIDI input to a track, and calls that bring tracks, clips and devices into view. + +Out of scope: Rendering or recording audio for analysis. + +## 3. User Stories + +* As an AI agent, I want to know whether and what a track is playing. +* As an AI agent, I want to play notes and send MIDI to an instrument to try a sound. +* As an AI agent, I want to bring what I changed into view for the user. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [12.1](../stories/12.1.story.md) Engine, Meters and Playing Notes | `set_engine_active`, `get_levels`, `get_playing_notes` | +| [12.2](../stories/12.2.story.md) Audition and MIDI | `play_notes`, `send_midi` | +| [12.3](../stories/12.3.story.md) Show the User | `show` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-12-listening-and-auditioning). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-13.md b/_bmad-output/planning-artifacts/prd/epic-13.md new file mode 100644 index 00000000..4777e11a --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-13.md @@ -0,0 +1,35 @@ +# Epic 13: Arrangement, Scenes, Clips and Notes + +## 1. Goal + +Complete the musical editing tools: arrangement markers and loop, scene management, clip management, reading and editing notes with all of Bitwig's per-note expressions and conditions, and the arranger clip the user selects. + +## 2. Background + +Epic 9 can create clips and write notes. Shaping music needs reading notes back, editing them in place (velocity, timing, expressions, chance, occurrence, recurrence, repeats), clip settings (loop, shuffle, accent) and moving material between slots and scenes. + +Out of scope: Placing or selecting arranger clips (the arranger cursor clip follows the user's selection only) and capturing playing clips into a scene (`createSceneFromPlayingLauncherClips` does nothing from an extension in Bitwig 6.1.3). + +## 3. User Stories + +* As an AI agent, I want to navigate and loop the arrangement. +* As an AI agent, I want to manage scenes. +* As an AI agent, I want to copy, move, duplicate and record clips. +* As an AI agent, I want to read the notes of a clip and change them in place. +* As an AI agent, I want to edit the arranger clip the user selected. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [13.1](../stories/13.1.story.md) Arrangement and Markers | `list_cue_markers`, `go_to_cue_marker`, `set_arrangement`, `stop_track` | +| [13.2](../stories/13.2.story.md) Scenes | `create_scene`, `rename_scene`, `set_scene`, `duplicate_scene`, `copy_scene`, `move_scene`, `delete_scene` | +| [13.3](../stories/13.3.story.md) Clip Management | `copy_clip`, `move_clip`, `duplicate_clip`, `record_into_slot`, `set_clip`, `transform_clip` | +| [13.4](../stories/13.4.story.md) Reading and Editing Notes | `read_notes`, `edit_notes` | +| [13.5](../stories/13.5.story.md) Arranger Clips | `write_arranger_notes` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-13-arrangement-scenes-clips-and-notes). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-14.md b/_bmad-output/planning-artifacts/prd/epic-14.md new file mode 100644 index 00000000..108ad46a --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-14.md @@ -0,0 +1,29 @@ +# Epic 14: Project-wide Control + +## 1. Goal + +Project-wide state and settings: stray solos and mutes, groove, transport options beyond play/stop, and the cue bus. + +## 2. Background + +A forgotten solo silences a whole project and is hard to spot from per-track tools. Groove, punch, pre-roll, metronome volume, launch quantization and the cue mix are project settings the API exposes through `Project`, `Groove` and `Transport`. + +Out of scope: Opening projects from disk. + +## 3. User Stories + +* As an AI agent, I want to see and clear solos, mutes and arms in one call. +* As an AI agent, I want to set groove and transport options. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [14.1](../stories/14.1.story.md) Project State and Reset | `project_state`, `reset_project` | +| [14.2](../stories/14.2.story.md) Groove, Transport Options and Cue Mix | `set_groove`, `set_transport_options`, `set_cue` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-14-project-wide-control). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-15.md b/_bmad-output/planning-artifacts/prd/epic-15.md new file mode 100644 index 00000000..6c1b092e --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-15.md @@ -0,0 +1,29 @@ +# Epic 15: Following the User + +## 1. Goal + +Let an agent follow what happens in Bitwig as it happens, instead of polling every tool (issue #3). + +## 2. Background + +Issue #3 asks for a way to listen for changes over the MCP stream. MCP Java SDK 0.11 sends progress and logging notifications during a call but does not handle resource subscriptions, so changes are offered as an event log that can be long-polled, and as a call that streams them. + +Out of scope: MCP resource subscriptions (`resources/subscribe`), until the SDK supports them. + +## 3. User Stories + +* As an AI agent, I want to get every change since I last looked, or wait for the next one. +* As an AI agent, I want to receive changes as notifications while a call runs. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [15.1](../stories/15.1.story.md) Event Log | `get_events` | +| [15.2](../stories/15.2.story.md) Streaming Changes | `watch` | + +Request schemas are in [api-reference.md](../api-reference.md#epic-15-following-the-user). + +## 5. Cross-cutting Rules + +The rules of [Epic 9](./epic-9.md#5-cross-cutting-rules) apply: track targeting by `track_index` or `track_name`, asynchronous changes confirmed by polling, `setImmediately()` for value writes, the user's selection is never moved (except by `show`), and all Bitwig proxies are created at init. diff --git a/_bmad-output/planning-artifacts/prd/epic-9.md b/_bmad-output/planning-artifacts/prd/epic-9.md new file mode 100644 index 00000000..a2c2405b --- /dev/null +++ b/_bmad-output/planning-artifacts/prd/epic-9.md @@ -0,0 +1,41 @@ +# Epic 9: Project Editing + +## 1. Goal + +To provide MCP tools that let an AI agent change a Bitwig project, not only read it: create and configure tracks, build device chains, set parameters on any device, create clips and write notes, adjust transport settings, and run Bitwig's own application actions. + +## 2. Background + +Epics 1–8 give agents transport control, clip and scene launching, parameter control of the *selected* device, and read access to tracks, scenes and devices. An agent that is asked to "put a vocoder on the backing vocals and match their reverb" can describe what to do but cannot do it, because every change still needs the user's hands. The Bitwig Controller API (version 19) already exposes track creation, device insertion by UUID/CLAP/VST3 id, insertion points for copying and moving devices, remote-control pages on any device, launcher clips and notes, and the application action list. This epic exposes those capabilities through MCP. + +Out of scope, because the Controller API does not expose them: audio routing and sidechain source selection, and placing clips on the arranger timeline (only launcher slots can be targeted). + +## 3. User Stories + +* As an AI agent, I want to create, delete and duplicate tracks and set their mixer state (name, volume in dB, pan, mute, solo, arm, activation, monitor mode, color, sends), so I can lay out a project for the user. +* As an AI agent, I want to insert Bitwig built-in devices by name and plugins by CLAP or VST3 id, and copy, move, bypass and delete devices, so I can build signal chains. +* As an AI agent, I want to read and set the parameters of any device on any track, across all of its remote-control pages, without changing what the user has selected, so I can configure a chain while the user keeps working. +* As an AI agent, I want to import audio and MIDI files into clip slots, create empty clips and write notes into them, so I can supply musical material. +* As an AI agent, I want to set tempo, play position, loop, metronome, record and overdub, list and invoke Bitwig application actions, and undo/redo, so I can reach functionality that has no dedicated tool and recover from mistakes. + +## 4. Proposed MCP Tools + +| Story | Tools | +|---|---| +| [9.1](../stories/9.1.story.md) Tracks and mixer | `create_track`, `delete_track`, `duplicate_track`, `get_track_mixer`, `set_track`, `set_send` | +| [9.2](../stories/9.2.story.md) Device chain editing | `list_bitwig_devices`, `insert_device`, `copy_device`, `move_device`, `delete_device`, `set_device_enabled`, `set_device_window_open` | +| [9.3](../stories/9.3.story.md) Parameters of any device | `get_device_parameters`, `set_device_parameters` | +| [9.4](../stories/9.4.story.md) Clips and notes | `insert_file`, `create_clip`, `delete_clip`, `write_notes` | +| [9.5](../stories/9.5.story.md) Transport settings and application actions | `set_transport`, `list_actions`, `invoke_action`, `undo`, `redo` | +| [9.6](../stories/9.6.story.md) Live end-to-end test | `scripts/live_test.py` | + +Request and response schemas are in [api-reference.md](../api-reference.md#project-editing-commands). + +## 5. Cross-cutting Rules + +* **Targeting:** every track-scoped tool accepts `track_index` (0-based, as in `list_tracks`) or `track_name` (exact, case-sensitive). Device-scoped tools add `device_index` (as in `list_devices_on_track`). +* **Asynchrony:** Bitwig applies changes asynchronously. Mutating tools poll (20 ms interval, 2 s timeout) until the change is visible and return the resulting state; a change that never appears returns `BITWIG_TIMEOUT`. +* **Value writes:** Bitwig ignores `SettableRangedValue.set()` from an extension that has no bound hardware control. All value writes (device parameters, volume, pan, sends) use `setImmediately()`. +* **Selection is never moved:** device parameters are reached through a dedicated cursor track (`followSelection = false`) and cursor device, not the user's cursor. +* **Track list layout:** the flat track bank lists regular tracks, then effect tracks, then the master track. Regular and effect tracks are counted separately; a newly created track's type is checked before it is renamed; the master track cannot be deleted or duplicated. +* **Objects are created at init:** all Bitwig proxies (cursor track/device, remote-control page, launcher cursor clip, send bank of 8) are created in the facade constructor, which runs during `init()`. diff --git a/_bmad-output/planning-artifacts/prd/index.md b/_bmad-output/planning-artifacts/prd/index.md index 3719a5f9..7f1f53c2 100644 --- a/_bmad-output/planning-artifacts/prd/index.md +++ b/_bmad-output/planning-artifacts/prd/index.md @@ -169,6 +169,27 @@ Validating WigAI's functionality will involve several layers: - **[Epic 8: Device Information Retrieval](./epic-8.md)** * **Goal:** Provide MCP tools to list devices on a specific track and retrieve detailed information including remote controls and remote control pages for a specific device. +- **[Epic 9: Project Editing](./epic-9.md)** + * **Goal:** Provide MCP tools to create and configure tracks, build device chains, set parameters on any device, create clips and write notes, adjust transport settings and run Bitwig application actions. + +- **[Epic 10: Device Chains, Presets and Containers](./epic-10.md)** + * **Goal:** Work inside container devices (Vocoder carrier/modulator, FX slots, layers), load presets, drive Bitwig's browser, and set up Drum Machine pads and layer mixers. + +- **[Epic 11: Device Knowledge](./epic-11.md)** + * **Goal:** List every installed Bitwig device and plugin, describe each device's controls (scanned on demand, with history) and store Bitwig's own device descriptions. + +- **[Epic 12: Listening and Auditioning](./epic-12.md)** + * **Goal:** Meters, playing notes and modulated values, auditioning notes and sending MIDI, and showing the user what changed. + +- **[Epic 13: Arrangement, Scenes, Clips and Notes](./epic-13.md)** + * **Goal:** Cue markers and loop, scene management, clip management, reading and editing notes with every expression and condition, and the arranger clip the user selects. + +- **[Epic 14: Project-wide Control](./epic-14.md)** + * **Goal:** Clear stray solos, mutes and arms; set groove, transport options and the cue mix. + +- **[Epic 15: Following the User](./epic-15.md)** + * **Goal:** An event log of changes with long-poll, and a call that streams changes as MCP notifications (issue #3). + ## Key Reference Documents - `docs/project-brief.md` diff --git a/_bmad-output/planning-artifacts/stories/10.1.story.md b/_bmad-output/planning-artifacts/stories/10.1.story.md new file mode 100644 index 00000000..248dd47a --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/10.1.story.md @@ -0,0 +1,32 @@ +# Story 10.1: Chains Inside Devices + +**Epic:** [Epic 10: Device Chains, Presets and Containers](../prd/epic-10.md) + +**User Story:** + +* As an AI agent, I want to read and edit the chains inside a device (slots and layers), so I can build a vocoder or a layered sound. + +**Acceptance Criteria:** + +* `get_device_chains` lists every slot (`Modulator`, `Carrier`, `FX`, ...) and layer (`layer:N`) of a device with the devices in each. +* `insert_device`, `copy_device`/`move_device`, `delete_device`, `set_device_enabled`, `get_device_parameters` and `set_device_parameters` accept `chain` (+ `chain_device_index`) to work on a device inside a chain; copy/move take `from_chain`/`to_chain`. +* Moving a device between two chains of the same container works. +* `api-reference.md` documents `get_device_chains`. + +### Implementation Notes + +* Slots are browsed one at a time with `CursorDeviceSlot.selectSlot(name)`; the selected slot belongs to the device, not the cursor, so moving between two slots of the same device uses the cursor itself as the source. +* Layers each get a `DeviceBank` created at init. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test.py`: 24/24 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/10.2.story.md b/_bmad-output/planning-artifacts/stories/10.2.story.md new file mode 100644 index 00000000..2484ac00 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/10.2.story.md @@ -0,0 +1,36 @@ +# Story 10.2: Presets and Bitwig's Browser + +**Epic:** [Epic 10: Device Chains, Presets and Containers](../prd/epic-10.md) + +**User Story:** + +* As an AI agent, I want to load presets and search Bitwig's library, so I can start from a designed sound. + +**Acceptance Criteria:** + +* `list_presets` indexes every `.bwpreset` under the installed packages and the user library and searches name, collection, device and category (all words must match). +* `insert_device` and `replace_device` take `preset_file`; `replace_device` swaps a device, plugin or preset in place. +* `browser_open` opens Bitwig's popup browser at an insertion point (`replace`, `after`, `before`, `start`, `end`); `browser_state` shows content types, each filter column's selection and a page of results; `browser_filter` sets the content type and filters by column and item name; `browser_commit` loads a result by name or index and reports what was loaded; `browser_cancel` closes it. +* Device lists report each device's loaded preset. +* `api-reference.md` documents `list_presets`, `replace_device`, `browser_open`, `browser_state`, `browser_filter`, `browser_commit`, `browser_cancel`. + +### Implementation Notes + +* Presets load through `InsertionPoint.insertFile`. +* The browser is `host.createPopupBrowser()` with an item bank of 64 per filter column and for the results; columns and results are paged with `scrollPosition`. +* Opened in `replace` mode on a device, Bitwig filters the browser to that device's presets. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigContainerFacade.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigContainerFacadeTest.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_container.py`: 19/19 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/10.3.story.md b/_bmad-output/planning-artifacts/stories/10.3.story.md new file mode 100644 index 00000000..93f3b28c --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/10.3.story.md @@ -0,0 +1,32 @@ +# Story 10.3: Drum Machine Pads and Layer Mixers + +**Epic:** [Epic 10: Device Chains, Presets and Containers](../prd/epic-10.md) + +**User Story:** + +* As an AI agent, I want to set up Drum Machine pads and balance the layers of a layer device. + +**Acceptance Criteria:** + +* `list_drum_pads` lists pads by MIDI key with note name, mixer state and devices; empty pads only with `include_empty`. +* `set_drum_pad` and `set_layer` set name, volume (raw or exact `volume_db`), pan, mute, solo, activation. +* `insert_into_drum_pad` adds a device, plugin, preset, or a sample file (Bitwig creates a Sampler). +* `clear_drum_pads` unmutes and/or unsolos every pad. +* `api-reference.md` documents `list_drum_pads`, `set_drum_pad`, `insert_into_drum_pad`, `clear_drum_pads`, `list_layers`, `set_layer`. + +### Implementation Notes + +* `DrumPadBank` of 128 with `scrollPosition` 0, so pad index = MIDI key; pads and layers are `Channel`s, so the track volume search is reused for `volume_db`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigContainerFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_container.py`: 19/19 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/11.1.story.md b/_bmad-output/planning-artifacts/stories/11.1.story.md new file mode 100644 index 00000000..234c8e09 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/11.1.story.md @@ -0,0 +1,33 @@ +# Story 11.1: Installed Devices and Plugins + +**Epic:** [Epic 11: Device Knowledge](../prd/epic-11.md) + +**User Story:** + +* As an AI agent, I want to search everything installed, built-in and plugin, so I know what I can use. + +**Acceptance Criteria:** + +* Built-in devices are read from Bitwig's library (`/Library/device-settings`), so every device of the running version is listed and insertable; a copy generated from it is bundled. +* CLAP, VST3 and VST2 plugins are read from Bitwig's plugin metadata cache (`~/.BitwigStudio/cache/clap-metadata-N`, `vst3-metadata-N`, `vst-metadata-N`) with id, vendor, category, version and features, without loading any plugin. +* Results carry the id that `insert_device` takes (`clap_id`, `vst3_id`, `vst2_id`). +* LV2 plugins usable through Carla can be added by `scripts/device_catalog.py inventory`. +* `api-reference.md` documents `list_installed_devices`. + +### Implementation Notes + +* The cache is Bitwig's undocumented tagged format; `PluginCache` reads only known tags (category 0x3e3, name 0x3e4, vendor 0x3e6, CLAP id 0x2ec4, VST id 0x3e2) and skips everything else. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/PluginCache.java` +- `src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/test/java/io/github/fabb/wigai/bitwig/PluginCacheTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_events.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/11.2.story.md b/_bmad-output/planning-artifacts/stories/11.2.story.md new file mode 100644 index 00000000..45064a75 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/11.2.story.md @@ -0,0 +1,32 @@ +# Story 11.2: Describe a Device, Scanning on Demand + +**Epic:** [Epic 11: Device Knowledge](../prd/epic-11.md) + +**User Story:** + +* As an AI agent, I want to know what each control of a device does, measured rather than guessed. + +**Acceptance Criteria:** + +* `describe_device` returns the stored facts; without a stored scan (or with `rescan`) it measures the controls now, on a given instance (values restored afterwards) or on a temporary track that is deleted. +* A scan sweeps each control and records its range, unit and options; every scan is kept in a history with what changed since the previous one. +* Scans are refused while the transport plays. +* `catalog_status` reports counts, recent scans, stale scans (older Bitwig version) and failures. +* `api-reference.md` documents `describe_device`, `catalog_status`. + +### Implementation Notes + +* The catalog is `~/.local/share/wigai/device-catalog.json` (or `$WIGAI_CATALOG_DIR`). + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `scripts/device_catalog.py` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_events.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/11.3.story.md b/_bmad-output/planning-artifacts/stories/11.3.story.md new file mode 100644 index 00000000..c7d89afe --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/11.3.story.md @@ -0,0 +1,27 @@ +# Story 11.3: Bitwig's Device Descriptions + +**Epic:** [Epic 11: Device Knowledge](../prd/epic-11.md) + +**User Story:** + +* As an AI agent, I want to read Bitwig's own description of each device. + +**Acceptance Criteria:** + +* `fetch_device_docs` downloads the device chapters of Bitwig's user guide and stores each built-in device's description under `bitwig_docs`, which `describe_device` returns. +* `api-reference.md` documents `fetch_device_docs`. + +### Implementation Notes + +* None beyond the epic's cross-cutting rules. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux): the user-guide pages were fetched and stored. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/12.1.story.md b/_bmad-output/planning-artifacts/stories/12.1.story.md new file mode 100644 index 00000000..b7209cb4 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/12.1.story.md @@ -0,0 +1,32 @@ +# Story 12.1: Engine, Meters and Playing Notes + +**Epic:** [Epic 12: Listening and Auditioning](../prd/epic-12.md) + +**User Story:** + +* As an AI agent, I want to know whether and what a track is playing. + +**Acceptance Criteria:** + +* `set_engine_active` activates the audio engine of the current project (only one open project has it). +* `get_levels` returns peak and RMS for every track and the master. +* `get_playing_notes` returns the notes sounding on each track. +* `get_device_parameters` adds `modulated_value`/`modulated_display` for controls a modulator is moving. +* `api-reference.md` documents `set_engine_active`, `get_levels`, `get_playing_notes`. + +### Implementation Notes + +* Meters use `addVuMeterObserver` per track; playing notes use `Channel.playingNotes()`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_midi.py`: 8/8 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/12.2.story.md b/_bmad-output/planning-artifacts/stories/12.2.story.md new file mode 100644 index 00000000..bb2fcab3 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/12.2.story.md @@ -0,0 +1,30 @@ +# Story 12.2: Audition and MIDI + +**Epic:** [Epic 12: Listening and Auditioning](../prd/epic-12.md) + +**User Story:** + +* As an AI agent, I want to play notes and send MIDI to an instrument to try a sound. + +**Acceptance Criteria:** + +* `play_notes` plays timed notes into a track's instrument without a clip. +* `send_midi` sends CC, pitch bend, program change, aftertouch or a raw channel message; values are range-checked. +* `api-reference.md` documents `play_notes`, `send_midi`. + +### Implementation Notes + +* Notes are scheduled with `host.scheduleTask` and `Track.startNote`/`stopNote`; MIDI goes through `Track.sendMidi`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigSessionFacadeTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_midi.py`: 8/8 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/12.3.story.md b/_bmad-output/planning-artifacts/stories/12.3.story.md new file mode 100644 index 00000000..1a2383ec --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/12.3.story.md @@ -0,0 +1,28 @@ +# Story 12.3: Show the User + +**Epic:** [Epic 12: Listening and Auditioning](../prd/epic-12.md) + +**User Story:** + +* As an AI agent, I want to bring what I changed into view for the user. + +**Acceptance Criteria:** + +* `show` switches the panel layout (ARRANGE, MIX, EDIT) and brings a track, launcher clip or device into view; it reports the layout Bitwig shows afterwards. +* `api-reference.md` documents `show`. + +### Implementation Notes + +* This intentionally changes the user's selection; every other tool avoids that. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_midi.py`: 8/8 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/13.1.story.md b/_bmad-output/planning-artifacts/stories/13.1.story.md new file mode 100644 index 00000000..6a0c0585 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/13.1.story.md @@ -0,0 +1,31 @@ +# Story 13.1: Arrangement and Markers + +**Epic:** [Epic 13: Arrangement, Scenes, Clips and Notes](../prd/epic-13.md) + +**User Story:** + +* As an AI agent, I want to navigate and loop the arrangement. + +**Acceptance Criteria:** + +* Cue markers are listed with positions and can be jumped to by index or name. +* `set_arrangement` sets the loop range in beats and the time signature. +* `stop_track` stops a track's launcher clip or returns it to the arrangement. +* `api-reference.md` documents `list_cue_markers`, `go_to_cue_marker`, `set_arrangement`, `stop_track`. + +### Implementation Notes + +* None beyond the epic's cross-cutting rules. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigSessionFacadeTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test.py`: 24/24 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/13.2.story.md b/_bmad-output/planning-artifacts/stories/13.2.story.md new file mode 100644 index 00000000..7ee54e16 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/13.2.story.md @@ -0,0 +1,29 @@ +# Story 13.2: Scenes + +**Epic:** [Epic 13: Arrangement, Scenes, Clips and Notes](../prd/epic-13.md) + +**User Story:** + +* As an AI agent, I want to manage scenes. + +**Acceptance Criteria:** + +* Scenes can be created, renamed, colored, duplicated, deleted, and have all their clips copied or moved onto another scene's row; results list every scene with its clip count. +* `api-reference.md` documents `create_scene`, `rename_scene`, `set_scene`, `duplicate_scene`, `copy_scene`, `move_scene`, `delete_scene`. + +### Implementation Notes + +* Copy and move use the target scene's `replaceInsertionPoint()`; duplicate uses `duplicateObject()`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_notes.py`: 16/16 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/13.3.story.md b/_bmad-output/planning-artifacts/stories/13.3.story.md new file mode 100644 index 00000000..0dba7100 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/13.3.story.md @@ -0,0 +1,33 @@ +# Story 13.3: Clip Management + +**Epic:** [Epic 13: Arrangement, Scenes, Clips and Notes](../prd/epic-13.md) + +**User Story:** + +* As an AI agent, I want to copy, move, duplicate and record clips. + +**Acceptance Criteria:** + +* Clips can be copied or moved to an empty slot on any track and duplicated into the next free slot. +* `record_into_slot` starts recording into an armed track's slot. +* `set_clip` sets name, loop on/off, loop start and length, play start and stop, shuffle, accent (0.5 = none) and color. +* `transform_clip` transposes, quantizes, duplicates content and sets launch quantization and mode. +* `api-reference.md` documents `copy_clip`, `move_clip`, `duplicate_clip`, `record_into_slot`, `set_clip`, `transform_clip`. + +### Implementation Notes + +* None beyond the epic's cross-cutting rules. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_project.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/13.4.story.md b/_bmad-output/planning-artifacts/stories/13.4.story.md new file mode 100644 index 00000000..d26a7a57 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/13.4.story.md @@ -0,0 +1,30 @@ +# Story 13.4: Reading and Editing Notes + +**Epic:** [Epic 13: Arrangement, Scenes, Clips and Notes](../prd/epic-13.md) + +**User Story:** + +* As an AI agent, I want to read the notes of a clip and change them in place. + +**Acceptance Criteria:** + +* `read_notes` returns every note (start and duration in beats, pitch, velocity, channel) and every expression or condition that is not at its default. +* `edit_notes` changes all notes or those matching a pitch and/or start range: velocity, release velocity, velocity spread, duration, gain, pan, timbre, pressure, micro-pitch, chance, occurrence, recurrence (length and mask), mute, repeats with curve and velocity shape. +* `api-reference.md` documents `read_notes`, `edit_notes`. + +### Implementation Notes + +* Notes are read through `addNoteStepObserver` on a launcher cursor clip (16384 x 128 steps of 1/16 beat). The note map must not be cleared after `selectChannel`, or notes delivered by it are lost. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_notes.py`: 16/16 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/13.5.story.md b/_bmad-output/planning-artifacts/stories/13.5.story.md new file mode 100644 index 00000000..a756be62 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/13.5.story.md @@ -0,0 +1,30 @@ +# Story 13.5: Arranger Clips + +**Epic:** [Epic 13: Arrangement, Scenes, Clips and Notes](../prd/epic-13.md) + +**User Story:** + +* As an AI agent, I want to edit the arranger clip the user selected. + +**Acceptance Criteria:** + +* `read_notes`, `edit_notes`, `set_clip` and `transform_clip` take `arranger: true` to work on the clip selected in the arranger; `write_arranger_notes` writes notes into it. +* With nothing selected, they return `CLIP_NOT_FOUND` explaining that the user must select a clip. +* `api-reference.md` documents `write_arranger_notes`. + +### Implementation Notes + +* Uses `host.createArrangerCursorClip`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_notes.py`: 16/16 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/14.1.story.md b/_bmad-output/planning-artifacts/stories/14.1.story.md new file mode 100644 index 00000000..f008c373 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/14.1.story.md @@ -0,0 +1,29 @@ +# Story 14.1: Project State and Reset + +**Epic:** [Epic 14: Project-wide Control](../prd/epic-14.md) + +**User Story:** + +* As an AI agent, I want to see and clear solos, mutes and arms in one call. + +**Acceptance Criteria:** + +* `project_state` reports soloed, muted and armed tracks, unsaved changes and the cue bus. +* `reset_project` unsolos, unmutes and/or unarms everything. +* `api-reference.md` documents `project_state`, `reset_project`. + +### Implementation Notes + +* None beyond the epic's cross-cutting rules. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_project.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/14.2.story.md b/_bmad-output/planning-artifacts/stories/14.2.story.md new file mode 100644 index 00000000..3b52a0ea --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/14.2.story.md @@ -0,0 +1,32 @@ +# Story 14.2: Groove, Transport Options and Cue Mix + +**Epic:** [Epic 14: Project-wide Control](../prd/epic-14.md) + +**User Story:** + +* As an AI agent, I want to set groove and transport options. + +**Acceptance Criteria:** + +* `set_groove` sets groove on/off, shuffle and accent amount, rate and phase. +* `set_transport_options` sets punch in/out, metronome volume, pre-roll, automation write mode, launcher overdub, default launch quantization and post-recording action, and runs rewind, fast forward, tap tempo, continue or record. +* `set_cue` sets cue volume and cue mix. +* `set_track` gains `group_expanded` and `crossfade_mode`. +* `api-reference.md` documents `set_groove`, `set_transport_options`, `set_cue`. + +### Implementation Notes + +* None beyond the epic's cross-cutting rules. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java` +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_project.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/15.1.story.md b/_bmad-output/planning-artifacts/stories/15.1.story.md new file mode 100644 index 00000000..e32c8c74 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/15.1.story.md @@ -0,0 +1,32 @@ +# Story 15.1: Event Log + +**Epic:** [Epic 15: Following the User](../prd/epic-15.md) + +**User Story:** + +* As an AI agent, I want to get every change since I last looked, or wait for the next one. + +**Acceptance Criteria:** + +* Changes are recorded with increasing sequence numbers: transport (play, record, loop, metronome, tempo, time signature), project (name, engine), track (name, mute, solo, arm, volume, pan, added/removed), clip (launcher playback state, clips created/deleted), selection (the user's track, device, preset) and parameter (the selected device's remote controls). +* `get_events` without `since_seq` returns a starting point; with it, the events after it, optionally filtered by type and waiting up to `wait_ms` for the first one. +* Rapid changes to one thing within 250 ms are merged into the latest; a full buffer (2000) reports `missed_events`. +* `api-reference.md` documents `get_events`. + +### Implementation Notes + +* Observers are registered at init; the burst of initial values is ignored for 2 s. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEventStream.java` +- `src/main/java/io/github/fabb/wigai/bitwig/EventBuffer.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/bitwig/EventBufferTest.java` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_events.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/15.2.story.md b/_bmad-output/planning-artifacts/stories/15.2.story.md new file mode 100644 index 00000000..7a90c33c --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/15.2.story.md @@ -0,0 +1,30 @@ +# Story 15.2: Streaming Changes + +**Epic:** [Epic 15: Following the User](../prd/epic-15.md) + +**User Story:** + +* As an AI agent, I want to receive changes as notifications while a call runs. + +**Acceptance Criteria:** + +* `watch` runs for `duration_ms` and sends each change as an MCP progress notification (when the call has a progress token) and as a logging notification (logger `wigai.events`), then returns all of them. +* Server instructions sent on initialize tell agents how to start, follow changes, and what the API cannot do; `docs/AGENT-GUIDE.md` has the full guide. +* `api-reference.md` documents `watch`. + +### Implementation Notes + +* Verified on the raw HTTP stream: notifications arrive as the changes happen, before the result. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java` +- `docs/AGENT-GUIDE.md` + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test_events.py`: 10/10 passed. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.1.story.md b/_bmad-output/planning-artifacts/stories/9.1.story.md new file mode 100644 index 00000000..553c8e33 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.1.story.md @@ -0,0 +1,48 @@ +# Story 9.1: Track Creation and Mixer Tools + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As an AI agent, I want to create, delete and duplicate tracks and set their mixer state (name, volume in dB, pan, mute, solo, arm, activation, monitor mode, color, sends), so I can lay out a project for the user. + +**Acceptance Criteria:** + +* MCP tools `create_track`, `delete_track`, `duplicate_track`, `get_track_mixer`, `set_track` and `set_send` are implemented and registered. +* `create_track` accepts `type` (`audio` | `instrument` | `effect`, required), `position` (optional) and `name` (optional). It returns the new track's `track_index` and `name`. + * For audio and instrument tracks `position` is an index among regular tracks; for effect tracks it is an index among effect tracks. Default is the end of that group. + * The tool waits until the track count of that group increases, then verifies that the track at the expected index exists, is not the master track, and has the requested type before applying `name`. If any check fails it returns an error and renames nothing. +* `delete_track` and `duplicate_track` refuse the master track with `INVALID_PARAMETER`. +* `set_track` changes only the fields that are given: + * `name`, `mute`, `solo`, `arm`, `activated`, `monitor_mode` (`ON` | `OFF` | `AUTO`), `color` (`#RRGGBB`). + * `volume`: raw normalized fader position 0–1. + * `volume_db`: target in dB. Because the fader curve is not exposed, the tool binary-searches the normalized value against Bitwig's displayed dB and keeps the position whose display was closest to the target (the display is rounded to 0.1 dB). + * `pan`: −1 (left) to 1 (right), mapped to the normalized 0–1 value. +* `get_track_mixer`, `set_track` and `set_send` return the full mixer state including sends (`index`, `name`, `value`, `display`, `enabled`). +* `set_send` sets a send's `value` (0–1) and/or `enabled`; a missing send returns `INVALID_RANGE`. +* `api-reference.md` documents all six tools. +* Unit tests cover track resolution, master-track protection, the regression where a slow-to-appear track caused the master to be renamed, pan/color mapping, and missing send banks. + +### Implementation Notes + +* The main track bank is created with 8 sends (previously 0) so sends are reachable; `BitwigApiFacadeTest` is updated accordingly. +* `Track.sendBank()` throws when the bank has no sends; access is wrapped so mixer reads still succeed. +* Send properties (`exists`, `name`, `value`, `displayedValue`, `isEnabled`) and `trackType` are marked interested at init. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` (new) +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` (new) +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigApiFacade.java` (modified: 8 sends, accessors) +- `src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java` (modified: registration) +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeTest.java` (new) +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` (new) +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigApiFacadeTest.java` (modified) + +### Completion Notes +- Verified live on Bitwig 6.1.3 (Linux) with `scripts/live_test.py`: create instrument/audio tracks, `volume_db=-3` lands on "-3.0 dB", pan, mute, color. +- An early version counted the master track as a regular track and renamed it instead of the new track. Fixed by counting track kinds separately and checking the new track's type before renaming; covered by a regression test. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.2.story.md b/_bmad-output/planning-artifacts/stories/9.2.story.md new file mode 100644 index 00000000..299bcd7b --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.2.story.md @@ -0,0 +1,42 @@ +# Story 9.2: Device Chain Editing Tools + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As an AI agent, I want to insert Bitwig built-in devices by name and plugins by CLAP or VST3 id, and copy, move, bypass and delete devices, so I can build signal chains. + +**Acceptance Criteria:** + +* MCP tools `list_bitwig_devices`, `insert_device`, `copy_device`, `move_device`, `delete_device`, `set_device_enabled` and `set_device_window_open` are implemented and registered. +* `list_bitwig_devices` returns the names of Bitwig's built-in devices known to the extension. +* `insert_device` takes exactly one of: + * `bitwig_device`: a built-in device name, matched case-insensitively (e.g. `EQ+`, `Vocoder`, `Reverb`); unknown names return `DEVICE_NOT_FOUND`. + * `clap_id`: e.g. `org.surge-synth-team.surge-xt`. + * `vst3_id`: the plugin's VST3 class id. + * None given → `MISSING_REQUIRED_PARAMETER`. +* Insertion position: `position` = `start` | `end` (default `end`), or `after_device_index` to insert directly after an existing device. +* `copy_device` and `move_device` take a source (`from_track_index`/`from_track_name`, `from_device_index`) and a destination (`to_track_index`/`to_track_name` plus the same position options). Copies keep all device settings. +* `delete_device` removes a device; `set_device_enabled` bypasses (`false`) or enables (`true`) it; `set_device_window_open` opens or closes a plugin window. +* Mutating tools return the resulting device chain (`index`, `name`, `enabled`) of the affected track. +* `api-reference.md` documents all seven tools. +* Unit tests cover insertion by name/CLAP id, position resolution, copy vs. move, unknown and missing device ids, and missing devices. + +### Implementation Notes + +* Built-in device UUIDs are bundled in `src/main/resources/bitwig-devices.txt` (`Name$uuid` per line), taken from DrivenByMoss's device lists (LGPL-3.0, attributed in the file), and inserted with `InsertionPoint.insertBitwigDevice(UUID)`. +* Copy and move use `InsertionPoint.copyDevices` / `moveDevices` on `Track.startOfDeviceChainInsertionPoint()`, `endOfDeviceChainInsertionPoint()` or `Device.afterDeviceInsertionPoint()`. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/main/resources/bitwig-devices.txt` (new) +- `src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeTest.java` + +### Completion Notes +- Verified live: insert EQ+ at end and Reverb after it, bypass/enable, copy EQ+ to another track, move Reverb to the start, delete. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.3.story.md b/_bmad-output/planning-artifacts/stories/9.3.story.md new file mode 100644 index 00000000..0b0018b6 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.3.story.md @@ -0,0 +1,39 @@ +# Story 9.3: Parameters of Any Device + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As an AI agent, I want to read and set the parameters of any device on any track, across all of its remote-control pages, without changing what the user has selected, so I can configure a chain while the user keeps working. + +**Acceptance Criteria:** + +* MCP tools `get_device_parameters` and `set_device_parameters` are implemented and registered. +* Both identify the device by track (`track_index`/`track_name`) and `device_index`. +* `get_device_parameters` returns `page_names` and, for every page (or only `page` if given), the existing controls with `index`, `name`, normalized `value` and `display`. +* `set_device_parameters` takes a non-empty `parameters` array. Each item has `value` (0–1, required) and either: + * `name`: matched case-insensitively, searched across all pages; or + * `page` + `index`. +* Each item gets its own result: on success `status`, `page`, `name`, `value`, `display` and `applied` (whether Bitwig reported the new value); on failure `status: "error"` and a `message` ("Parameter not found", "value must be 0.0-1.0"). One failing item does not stop the others. +* Neither tool changes the user's selected track, device or remote-control page. +* `api-reference.md` documents both tools. +* Unit tests cover argument mapping (name vs. page+index, required `value`). + +### Implementation Notes + +* A dedicated `CursorTrack` (`createCursorTrack(id, name, 0, 0, false)`) and `PinnableCursorDevice` with a `CursorRemoteControlsPage` of 8 are created at init. The tools point them at the target with `selectChannel` / `selectDevice`, wait until the cursor reports the expected track name, device position and device name, then switch pages via `selectedPageIndex()`. +* **Bitwig ignores `value().set()` on these remote controls** (it is accepted but never applied; reading works). `value().setImmediately()` applies. This was isolated by trying `set()` on the control thread (`host.scheduleTask`), `set()` on the request thread, and `setImmediately()` in sequence; only the last changed the value. +* This removes the limitation noted in Story 8.2 that remote controls of non-selected devices are not accessible. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` +- `src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java` + +### Completion Notes +- Verified live on a Reverb: pages `Main`, `Tone`, `Common` read correctly; setting `Pre-delay` to 0.9 shows "90.0 ms"; setting by name and by page+index both apply. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.4.story.md b/_bmad-output/planning-artifacts/stories/9.4.story.md new file mode 100644 index 00000000..f58cc7a2 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.4.story.md @@ -0,0 +1,36 @@ +# Story 9.4: Clips and Notes + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As an AI agent, I want to import audio and MIDI files into clip slots, create empty clips and write notes into them, so I can supply musical material. + +**Acceptance Criteria:** + +* MCP tools `insert_file`, `create_clip`, `delete_clip` and `write_notes` are implemented and registered. +* `insert_file` takes an absolute `path` (must exist, else `INVALID_PARAMETER`) and a track: + * with `slot_index`: the file is inserted into that launcher slot (`ClipLauncherSlot.replaceInsertionPoint().insertFile`); the result reports whether the slot now has content. + * without `slot_index`: Bitwig inserts it after the track (`Track.afterTrackInsertionPoint().insertFile`), creating a new track. +* `create_clip` creates an empty launcher clip of `length_beats` in an empty slot; an occupied slot returns `INVALID_PARAMETER`. +* `delete_clip` deletes the clip in a slot. +* `write_notes` writes `notes` (`start` and `duration` in beats from the clip start, `pitch` 0–127, `velocity` 1–127, default 100) into an existing clip on a 1/64-beat grid. `clear_first: true` removes existing notes first. An empty slot returns `CLIP_NOT_FOUND`. +* `api-reference.md` documents all four tools. +* Unit tests cover missing files and note argument mapping (default velocity). + +### Implementation Notes + +* Notes are written through a launcher cursor clip created at init on the private edit cursor track (`createLauncherCursorClip(16384, 128)`, step size 1/16 beat). The target slot is selected so the cursor clip follows it. +* The Controller API cannot place clips on the arranger timeline; that is out of scope. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live: create a 4-beat clip, write two notes, delete the clip, import a generated one-bar MIDI file into a slot. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.5.story.md b/_bmad-output/planning-artifacts/stories/9.5.story.md new file mode 100644 index 00000000..f604ec01 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.5.story.md @@ -0,0 +1,29 @@ +# Story 9.5: Transport Settings and Application Actions + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As an AI agent, I want to set tempo, play position, loop, metronome, record and overdub, list and invoke Bitwig application actions, and undo/redo, so I can reach functionality that has no dedicated tool and recover from mistakes. + +**Acceptance Criteria:** + +* MCP tools `set_transport`, `list_actions`, `invoke_action`, `undo` and `redo` are implemented and registered. +* `set_transport` changes only given fields: `tempo_bpm` (set via the tempo's raw value), `position_beats`, `loop`, `metronome`, `record`, `overdub`. It returns the resulting transport state. +* `list_actions` returns `id`, `name` and `category` for every Bitwig application action, optionally filtered by a case-insensitive substring of id, name or category. +* `invoke_action` runs an action by `id`; an unknown id returns `INVALID_PARAMETER`. +* `undo` and `redo` call `Application.undo()` / `redo()`. +* `api-reference.md` documents all five tools. +* Unit tests cover action filtering and unknown action ids. + +## Dev Agent Record + +### File List +- `src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java` +- `src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java` + +### Completion Notes +- Verified live: `list_actions` filter "metronome", toggling the metronome via `set_transport` and restoring it. + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/stories/9.6.story.md b/_bmad-output/planning-artifacts/stories/9.6.story.md new file mode 100644 index 00000000..459add96 --- /dev/null +++ b/_bmad-output/planning-artifacts/stories/9.6.story.md @@ -0,0 +1,29 @@ +# Story 9.6: Live End-to-End Test + +**Epic:** [Epic 9: Project Editing](../prd/epic-9.md) + +**User Story:** + +* As a developer, I want a script that exercises the editing tools against a running Bitwig over MCP, so that behaviour the unit tests cannot see (asynchronous updates, values Bitwig silently ignores) is verified before release. + +**Acceptance Criteria:** + +* `scripts/live_test.py` (Python 3 standard library only) connects to the MCP endpoint (`--url`, default `http://localhost:61169/mcp`), performs the MCP handshake, and calls tools over the streamable HTTP transport. +* It refuses to run (exit code 2) unless the open project has at most 2 regular tracks; `--force` overrides. The script creates, edits and deletes tracks and devices and must never be pointed at a real song. +* It creates its own instrument and audio tracks and checks: track naming, `volume_db`, pan, mute, color; device insert/position/bypass/copy/move/delete; reading all parameter pages and setting parameters by name and by page+index; clip create/write notes/delete; MIDI file import; action listing; metronome toggle (restored afterwards). +* It deletes its tracks at the end (unless `--keep`) and checks that the regular track count is back to where it started. +* It prints one line per check and exits non-zero if any check fails. + +## Dev Agent Record + +### File List +- `scripts/live_test.py` (new) +- `README.md` (modified: Testing section) +- `_bmad-output/planning-artifacts/testing/mcp-endpoints-verification.md` (modified) + +### Completion Notes +- Result on Bitwig 6.1.3 (Linux): 24/24 checks pass. +- The empty-project guard was added after an early run against a real project renamed its master track (see Story 9.1). + +### Status +Complete diff --git a/_bmad-output/planning-artifacts/testing/mcp-endpoints-verification.md b/_bmad-output/planning-artifacts/testing/mcp-endpoints-verification.md index f5865747..89cc5ce4 100644 --- a/_bmad-output/planning-artifacts/testing/mcp-endpoints-verification.md +++ b/_bmad-output/planning-artifacts/testing/mcp-endpoints-verification.md @@ -164,7 +164,32 @@ Expected response format: - `name`: Should exactly match scene names as displayed in Bitwig - `color`: Should be `rgb(r,g,b)` format or `null` for scenes without colors -### 5. Check Log Messages +### 5. Automated Live Test (Epic 9 editing tools) + +`scripts/live_test.py` runs the editing tools end to end against a running Bitwig: + +```bash +python3 scripts/live_test.py # default URL http://localhost:61169/mcp +python3 scripts/live_test.py --keep # leave the test tracks for inspection +``` + +It creates its own tracks, exercises tracks/mixer, devices, parameters, clips, notes, file import and actions, then deletes them and checks the track count is restored. **Open an empty project first**: the script refuses to run when the project has more than 2 regular tracks (`--force` overrides). It prints one line per check and exits non-zero on any failure. + +### 5a. Automated Live Tests (Epics 10-15) + +Each script follows the same rules as `live_test.py` (empty project only, creates and deletes its own tracks, one line per check, non-zero exit on failure): + +| Script | Covers | Checks | +|---|---|---| +| `scripts/live_test_container.py` | presets on disk, `replace_device`, the browser, drum pads, layer mixers (Epic 10) | 19 | +| `scripts/live_test_notes.py` | note expressions and conditions, clip settings, scenes, cue mix, crossfade, arranger clips (Epics 13, 14) | 16 | +| `scripts/live_test_project.py` | project state, groove, transport options, clip copy/move/duplicate, note editing (Epics 13, 14) | 10 | +| `scripts/live_test_midi.py` | playing notes, MIDI, modulated values, `show` (Epic 12) | 8 | +| `scripts/live_test_events.py` | plugin inventory and VST2/VST3 insertion, `get_events` long-poll, `watch` streaming on the raw HTTP stream (Epics 11, 15) | 10 | + +All pass on Bitwig 6.1.3, Linux. Some tests switch the audio engine to the open project (`set_engine_active`) and play the transport briefly with the test track silenced. + +### 6. Check Log Messages After running these tests, check the Bitwig extension console to verify that appropriate log messages are being generated for each request and response. diff --git a/docs/AGENT-GUIDE.md b/docs/AGENT-GUIDE.md new file mode 100644 index 00000000..0866c849 --- /dev/null +++ b/docs/AGENT-GUIDE.md @@ -0,0 +1,71 @@ +# Using WigAI from an agent + +How the tools fit together, for AI agents (and the people setting them up). Tool descriptions carry the details; +this page gives the workflows and the limits. + +## Find your way around a project + +1. `status` for the project, transport and selection; `list_tracks` for every track with its devices. +2. `get_track_mixer`, `get_device_chains` (slots such as a Vocoder's Modulator/Carrier, and layers) and + `get_device_parameters` (every remote-control page) to read the details without moving the user's selection. +3. `project_state` tells you if a solo, mute or arm is hiding something; `reset_project` clears them. + +Groups that are collapsed in Bitwig hide their tracks from the track list. `set_track` with `group_expanded` +shows them. + +## Know what is installed, and what it does + +- `list_installed_devices` searches Bitwig's devices and every plugin Bitwig has scanned (CLAP, VST3, VST2, + read from Bitwig's own plugin cache in `~/.BitwigStudio/cache`), plus LV2 plugins usable through Carla when + `scripts/device_catalog.py inventory` has been run. +- `describe_device` returns what is known about one device. Without a stored scan it measures the controls now + (on a temporary track, or on an instance you name) and keeps a scan history. +- `fetch_device_docs` stores Bitwig's own user-guide description of each built-in device. +- The catalog is facts, not advice. Decide what to use from those facts; optional personal notes can live in + `device-guide.json` next to the catalog file and come back as `user_notes`. + +## Build sounds + +- `insert_device` takes a Bitwig device name, a `clap_id`, `vst3_id` or `vst2_id` (ids from + `list_installed_devices`), or a `preset_file`. +- `list_presets` searches the preset files on disk. Presets from installed packages are encrypted, so their + device is unknown on disk; `browser_open` with mode `replace` on a device lists that device's presets. +- `browser_open` / `browser_filter` / `browser_commit` drive Bitwig's own browser (content types, categories, + tags, creators). It shows in Bitwig's window until committed or cancelled. +- `replace_device` swaps a device for another device or a preset, in place. +- Drum Machines: `list_drum_pads`, `set_drum_pad`, `insert_into_drum_pad` (a sample file becomes a Sampler). + Layer devices: `list_layers`, `set_layer`, and chain `layer:N` on the device tools. +- `set_device_parameters` sets any control by name or page and index; `play_notes` auditions a sound without + writing a clip. `get_device_parameters` shows `modulated_value` for controls a modulator is moving. + +## Write and shape music + +- Launcher clips: `create_clip`, `write_notes`, `read_notes`, `edit_notes` (velocity, expressions, chance, + occurrence, recurrence, repeats), `transform_clip`, `set_clip`, `copy_clip`, `move_clip`, `duplicate_clip`. +- Arranger clips: the API cannot place or select them. When the user selects one, `arranger: true` on the + clip tools and `write_arranger_notes` work on it. +- Scenes: `create_scene`, `set_scene`, `duplicate_scene`, `copy_scene`, `move_scene`, `delete_scene`. + +## Follow what the user does + +- `get_events` returns changes since a sequence number: transport, project switches, track settings, clip + launches, the user's selected track/device/preset, and the selected device's knobs as they move. Call it + once without `since_seq` to get a starting point, then pass `next_seq` back; `wait_ms` waits for the next + change instead of polling. +- `watch` streams changes for a while as MCP progress and log notifications, then returns them all. +- `get_playing_notes` and `get_levels` show what is sounding right now (the project needs the audio engine: + `set_engine_active`). + +## Work safely in someone's song + +- Prefer reading tools until you know the project; every edit is undoable with `undo`, but a crash loses + unsaved work, so suggest saving before larger changes. +- Do not experiment with untried tools in a real song. `scripts/live_test_*.py` run only against an empty + project for that reason. + +## Not possible through Bitwig's API + +Routing (track inputs/outputs, Audio Receiver sources, sidechains), placing clips on the arranger timeline, +moving or copying tracks, adding modulators or automation points, adding or deleting layers, emptying a drum +pad, capturing playing clips into a scene, and opening projects from disk. Ask the user to do these by hand. +See [ROADMAP.md](../ROADMAP.md) for details. diff --git a/docs/api-coverage.md b/docs/api-coverage.md new file mode 100644 index 00000000..72b7f877 --- /dev/null +++ b/docs/api-coverage.md @@ -0,0 +1,113 @@ +# Bitwig Controller API coverage + +Agent-relevant interfaces: 383 of 1205 methods referenced by name (31%). Generated by `scripts/api_coverage.py`. + +| Interface | Used | Not yet used | +|---|---|---| +| `Action` | 4 | `getMenuItemText` | +| `ActionCategory` | 3 | — | +| `Application` | 17 | `getActionCategories`, `getActionCategory`, `addHasActiveEngineObserver`, `addProjectNameObserver`, `navigateIntoTrackGroup`, `navigateToParentTrackGroup`, `undoAction`, `canUndo`, `redoAction`, `canRedo`, `nextPanelLayout`, `previousPanelLayout`, `addPanelLayoutObserver`, `displayProfile`, `addDisplayProfileObserver`, `toggleInspector`, `toggleDevices`, `toggleMixer`, `toggleNoteEditor`, `toggleAutomationEditor`, `toggleBrowserVisibility`, `previousSubPanel`, `nextSubPanel`, `arrowKeyLeft`, `arrowKeyRight`, `arrowKeyUp`, `arrowKeyDown`, `enter`, `escape`, `selectAll`, `selectAllAction`, `selectNone`, `selectNoneAction`, `selectPrevious`, `selectPreviousAction`, `selectNext`, `selectNextAction`, `selectFirst`, `selectFirstAction`, `selectLast`, `selectLastAction`, `cut`, `cutAction`, `copy`, `copyAction`, `paste`, `pasteAction`, `duplicateAction`, `removeAction`, `rename`, `zoomIn`, `zoomInAction`, `zoomOut`, `zoomOutAction`, `zoomLevel`, `zoomToSelection`, `zoomToSelectionAction`, `zoomToSelectionOrAll`, `zoomToSelectionOrAllAction`, `zoomToSelectionOrPrevious`, `zoomToSelectionOrPreviousAction`, `zoomToFit`, `zoomToFitAction`, `focusPanelToLeft`, `focusPanelToRight`, `focusPanelAbove`, `focusPanelBelow`, `toggleFullScreen`, `setPerspective`, `nextPerspective`, `previousPerspective`, `addSelectedModeObserver`, `recordQuantizationGrid`, `recordQuantizeNoteLength` | +| `Arpeggiator` | 2 | `mode`, `octaves`, `isFreeRunning`, `rate`, `gateLength`, `enableOverlappingNotes`, `usePressureToVelocity`, `releaseNotes`, `humanize`, `terminateNotesImmediately` | +| `Arranger` | 1 | `isPlaybackFollowEnabled`, `hasDoubleRowTrackHeight`, `areCueMarkersVisible`, `isClipLauncherVisible`, `isTimelineVisible`, `isIoSectionVisible`, `areEffectTracksVisible`, `zoomInLaneHeightsAllAction`, `zoomInLaneHeightsAll`, `zoomOutLaneHeightsAllAction`, `zoomOutLaneHeightsAll`, `zoomLaneHeightsAllStepper`, `zoomInLaneHeightsSelectedAction`, `zoomInLaneHeightsSelected`, `zoomOutLaneHeightsSelectedAction`, `zoomOutLaneHeightsSelected`, `zoomLaneHeightsSelectedStepper`, `addPlaybackFollowObserver`, `addTrackRowHeightObserver`, `addCueMarkerVisibilityObserver`, `togglePlaybackFollow`, `toggleTrackRowHeight`, `toggleCueMarkerVisibility` | +| `AsyncTransferCompledCallback` | 0 | `asyncTransferCompleted` | +| `Bank` | 2 | `getCapacityOfBank`, `setSizeOfBank`, `itemCount`, `cursorIndex`, `setSkipDisabledItems` | +| `BeatTimeFormatter` | 0 | `formatBeatTime` | +| `BeatTimeValue` | 1 | `getFormatted`, `getFormatted`, `addTimeObserver` | +| `BitwigBrowsingSession` | 0 | `getCreatorFilter`, `getTagsFilter` | +| `Browser` | 0 | `addIsBrowsingObserver`, `startBrowsing`, `cancelBrowsing`, `commitSelectedResult`, `activateSession`, `isWindowMinimized`, `shouldAudition`, `createSessionBank`, `createCursorSession`, `getDeviceSession`, `getPresetSession`, `getSampleSession`, `getMultiSampleSession`, `getClipSession`, `getMusicSession` | +| `BrowserColumn` | 3 | `addExistsObserver`, `addEntryCountObserver` | +| `BrowserFilterColumn` | 5 | `getWildcardItem`, `addNameObserver` | +| `BrowserFilterColumnBank` | 0 | `getSize`, `getItem`, `scrollUp`, `scrollDown`, `scrollPageUp`, `scrollPageDown`, `addScrollPositionObserver`, `addCanScrollUpObserver`, `addCanScrollDownObserver`, `addEntryCountObserver` | +| `BrowserFilterItem` | 0 | `hitCount`, `addHitCountObserver` | +| `BrowserItem` | 3 | `addExistsObserver` | +| `BrowserItemBank` | 0 | `getSize`, `getItem`, `scrollUp`, `scrollDown`, `scrollPageUp`, `scrollPageDown`, `addScrollPositionObserver`, `addCanScrollUpObserver`, `addCanScrollDownObserver` | +| `BrowserResultsColumn` | 4 | — | +| `BrowsingSession` | 0 | `addIsAvailableObserver`, `addIsActiveObserver`, `activate`, `getResults`, `getCursorResult`, `getSettledResult`, `getCursorFilter`, `createFilterBank`, `hitCount`, `addHitCountObserver` | +| `BrowsingSessionBank` | 0 | `getSize`, `getSession`, `scrollUp`, `scrollDown`, `scrollPageUp`, `scrollPageDown`, `addScrollPositionObserver`, `addCanScrollUpObserver`, `addCanScrollDownObserver`, `addEntryCountObserver` | +| `ChainSelector` | 0 | `activeChainIndex`, `chainCount`, `activeChain`, `cycleNext`, `cyclePrevious` | +| `Channel` | 14 | `getVolume`, `getPan`, `getMute`, `getSolo`, `addNoteObserver`, `addColorObserver`, `getSend`, `addIsSelectedInMixerObserver` | +| `ChannelBank` | 0 | `getChannel`, `setChannelScrollStepSize`, `scrollChannelsPageUp`, `scrollChannelsPageDown`, `scrollChannelsUp`, `scrollChannelsDown`, `scrollToChannel`, `channelScrollPosition`, `addChannelScrollPositionObserver`, `canScrollChannelsUp`, `addCanScrollChannelsUpObserver`, `canScrollChannelsDown`, `addCanScrollChannelsDownObserver`, `channelCount`, `addChannelCountObserver`, `scrollSendsPageUp`, `scrollSendsPageDown`, `scrollSendsUp`, `scrollSendsDown`, `scrollToSend`, `addCanScrollSendsUpObserver`, `addCanScrollSendsDownObserver`, `addSendCountObserver` | +| `Clip` | 26 | `scrollKeysPageUp`, `scrollKeysPageDown`, `scrollKeysStepUp`, `scrollKeysStepDown`, `scrollStepsPageForward`, `scrollStepsPageBackwards`, `scrollStepsStepForward`, `scrollStepsStepBackwards`, `canScrollKeysUp`, `addCanScrollKeysUpObserver`, `canScrollKeysDown`, `addCanScrollKeysDownObserver`, `canScrollStepsBackwards`, `addCanScrollStepsBackwardsObserver`, `canScrollStepsForwards`, `addCanScrollStepsForwardObserver`, `toggleStep`, `toggleStep`, `clearStep`, `clearStep`, `clearStepsAtX`, `clearStepsAtY`, `moveStep`, `moveStep`, `selectStepContents`, `selectStepContents`, `addStepDataObserver`, `playingStep`, `addPlayingStepObserver`, `addColorObserver`, `useLoopStartAsQuantizationReference`, `launchLegato`, `getStep`, `launchWithOptions`, `clipLauncherSlot` | +| `ClipBrowsingSession` | 0 | `getFileTypeFilter` | +| `ClipLauncherSlot` | 13 | `browseToInsertClip`, `selectAction`, `recordAction` | +| `ClipLauncherSlotBank` | 8 | `addIsSelectedObserver`, `addIsPlayingObserver`, `addIsRecordingObserver`, `addIsPlaybackQueuedObserver`, `addIsRecordingQueuedObserver`, `addIsStopQueuedObserver`, `addIsQueuedObserver`, `addColorObserver`, `setIndication`, `isMasterTrackContentShownOnTrackGroups` | +| `ClipLauncherSlotOrScene` | 5 | `launchAction`, `launchAlt`, `launchAltAction`, `launchRelease`, `launchReleaseAction`, `launchReleaseAlt`, `launchReleaseAltAction`, `launchWithOptions`, `launchWithOptionsAction`, `launchLastClipWithOptions`, `launchLastClipWithOptionsAction`, `copyFrom`, `moveTo`, `setIndication`, `nextSceneInsertionPoint`, `previousSceneInsertionPoint` | +| `ClipLauncherSlotOrSceneBank` | 3 | `launchAlt`, `stopAlt`, `stopAction`, `stopAltAction`, `addNameObserver` | +| `ControllerHost` | 22 | `restart`, `loadAPI`, `useBetaApi`, `shouldFailOnDeprecatedUse`, `setShouldFailOnDeprecatedUse`, `load`, `platformIsWindows`, `platformIsMac`, `platformIsLinux`, `defineController`, `defineMidiPorts`, `getMidiInPort`, `getMidiOutPort`, `hardwareDevice`, `addDeviceNameBasedDiscoveryPair`, `defineSysexIdentityReply`, `getDocumentState`, `getNotificationSettings`, `createMixer`, `createMixer`, `createMixer`, `createMixer`, `createDetailEditor`, `createDetailEditor`, `createMainTrackBank`, `createEffectTrackBank`, `createEffectTrackBank`, `createArrangerCursorTrack`, `createEditorCursorDevice`, `createEditorCursorDevice`, `createCursorClip`, `createUserControls`, `requestFlush`, `errorln`, `createRemoteConnection`, `connectToRemoteHost`, `sendDatagramPacket`, `addDatagramPacketObserver`, `defineController`, `createTransportSection`, `createGrooveSection`, `createApplicationSection`, `createArrangerSection`, `createMixerSection`, `createTrackBankSection`, `createMainTrackBankSection`, `createEffectTrackBankSection`, `createCursorTrackSection`, `createMasterTrackSection`, `createCursorClipSection`, `createCursorDeviceSection`, `createUserControlsSection`, `defineSysexDiscovery`, `defaultBeatTimeFormatter`, `setDefaultBeatTimeFormatter`, `createBeatTimeFormatter`, `createHardwareSurface`, `createOrHardwareActionMatcher`, `createOrRelativeHardwareValueMatcher`, `createOrAbsoluteHardwareValueMatcher`, `midiExpressions`, `createCallbackAction`, `createAction`, `createPressureCallbackAction`, `createAction`, `createRelativeHardwareControlStepTarget`, `createRelativeHardwareControlAdjustmentTarget`, `createAbsoluteHardwareControlAdjustmentTarget`, `deleteObjects`, `deleteObjects`, `duplicateObjects`, `duplicateObjects`, `createInstrumentMatcher`, `createAudioEffectMatcher`, `createNoteEffectMatcher`, `createBitwigDeviceMatcher`, `createVST2DeviceMatcher`, `createVST3DeviceMatcher`, `createActiveDeviceMatcher`, `createFirstDeviceInChainMatcher`, `createLastDeviceInChainMatcher`, `createOrDeviceMatcher`, `createAndDeviceMatcher`, `createNotDeviceMatcher` | +| `CueMarker` | 4 | `getColor` | +| `CueMarkerBank` | 0 | `scrollToMarker` | +| `Cursor` | 1 | `selectPrevious`, `selectPreviousAction`, `selectNext`, `selectNextAction`, `selectFirst`, `selectLast`, `hasPrevious`, `addCanSelectPreviousObserver`, `addCanSelectNextObserver` | +| `CursorBrowserFilterItem` | 0 | `selectParent`, `selectFirstChild`, `selectLastChild`, `moveToPrevious`, `moveToNext`, `moveToFirst`, `moveToLast`, `moveToParent`, `moveToFirstChild`, `moveToLastChild` | +| `CursorBrowserItem` | 0 | `createSiblingsBank` | +| `CursorChannel` | 1 | — | +| `CursorClip` | 0 | `selectClip` | +| `CursorDevice` | 2 | `getChannel`, `selectParent`, `selectFirstInChannel`, `selectLastInChannel`, `selectFirstInSlot`, `selectLastInSlot`, `selectFirstInKeyPad`, `selectLastInKeyPad`, `selectFirstInLayer`, `selectLastInLayer`, `selectFirstInLayer`, `selectLastInLayer` | +| `CursorDeviceSlot` | 1 | — | +| `CursorRemoteControlsPage` | 2 | `selectNextPage`, `selectPreviousPage`, `selectNextPageMatching`, `selectPreviousPageMatching`, `pageCount`, `createPresetPage`, `createPresetPageAction` | +| `CursorTrack` | 9 | `selectParent`, `selectFirstChild`, `setCursorNavigationMode` | +| `DeleteableObject` | 1 | `deleteObjectAction` | +| `DetailEditor` | 0 | `zoomInLaneHeightsAction`, `zoomInLaneHeights`, `zoomOutLaneHeightsAction`, `zoomOutLaneHeights`, `zoomLaneHeightsStepper` | +| `Device` | 21 | `getDeviceChain`, `deviceChain`, `addPositionObserver`, `isExpanded`, `isMacroSectionVisible`, `isRemoteControlsSectionVisible`, `isParameterPageSectionVisible`, `getEnvelopeParameter`, `getCommonParameter`, `getModulationSource`, `getMacro`, `addHasSelectedDeviceObserver`, `isPlugin`, `addIsPluginObserver`, `previousParameterPage`, `nextParameterPage`, `addPreviousParameterPageEnabledObserver`, `addNextParameterPageEnabledObserver`, `setParameterPage`, `switchToPreviousPreset`, `switchToNextPreset`, `switchToPreviousPresetCategory`, `switchToNextPresetCategory`, `switchToPreviousPresetCreator`, `switchToNextPresetCreator`, `createDeviceBrowser`, `addNameObserver`, `addPresetNameObserver`, `presetCategory`, `addPresetCategoryObserver`, `presetCreator`, `addPresetCreatorObserver`, `addSelectedPageObserver`, `addActiveModulationSourceObserver`, `addPageNamesObserver`, `addPresetNamesObserver`, `loadPreset`, `addPresetCategoriesObserver`, `setPresetCategory`, `addPresetCreatorsObserver`, `setPresetCreator`, `toggleEnabledState`, `addIsEnabledObserver`, `addSlotsObserver`, `createCursorLayer`, `createChainSelector`, `createSpecificBitwigDevice`, `createSpecificVst2Device`, `createSpecificVst2Device`, `createSpecificVst3Device`, `createSpecificVst3Device`, `addDirectParameterIdObserver`, `addDirectParameterNameObserver`, `addDirectParameterValueDisplayObserver`, `addDirectParameterNormalizedValueObserver`, `setDirectParameterValueNormalized`, `incDirectParameterValueNormalized`, `sampleName`, `addSampleNameObserver`, `createSiblingsDeviceBank`, `browseToInsertBeforeDevice`, `browseToInsertAfterDevice`, `browseToReplaceDevice` | +| `DeviceBank` | 0 | `getDeviceChain`, `getDevice`, `scrollPageUp`, `scrollPageDown`, `scrollUp`, `scrollDown`, `scrollTo`, `addScrollPositionObserver`, `addCanScrollUpObserver`, `addCanScrollDownObserver`, `addDeviceCountObserver`, `browseToInsertDevice`, `setDeviceMatcher` | +| `DeviceBrowsingSession` | 0 | `getCategoryFilter`, `getDeviceTypeFilter`, `getFileTypeFilter` | +| `DeviceChain` | 6 | `addNameObserver`, `addIsSelectedInEditorObserver`, `addIsSelectedObserver`, `createDeviceBrowser`, `browseToInsertAtStartOfChain`, `browseToInsertAtEndOfChain` | +| `DeviceLayerBank` | 0 | `getChannel`, `getChannel` | +| `DirectParameterValueDisplayObserver` | 0 | `setObservedParameterIds` | +| `DoubleValue` | 1 | `getAsDouble` | +| `DrumPad` | 1 | — | +| `DrumPadBank` | 4 | `setIndication` | +| `DuplicableObject` | 1 | `duplicateObjectAction` | +| `EnumDefinition` | 0 | `getValueCount`, `valueDefinitionAt`, `valueDefinitionFor` | +| `EnumValue` | 2 | `enumDefinition` | +| `EnumValueDefinition` | 1 | `enumDefinition`, `getValueIndex`, `getDisplayName`, `getLimitedDisplayName` | +| `GenericBrowsingSession` | 1 | `addNameObserver` | +| `Groove` | 6 | — | +| `InsertionPoint` | 10 | `copyTracks`, `moveTracks`, `paste`, `browseAction` | +| `Macro` | 0 | `getAmount`, `getModulationSource`, `addLabelObserver` | +| `Mixer` | 0 | `isMeterSectionVisible`, `isIoSectionVisible`, `isSendSectionVisible`, `isClipLauncherSectionVisible`, `isDeviceSectionVisible`, `isCrossFadeSectionVisible`, `zoomInTrackWidthsAllAction`, `zoomInTrackWidthsAll`, `zoomOutTrackWidthsAllAction`, `zoomOutTrackWidthsAll`, `zoomTrackWidthsAllStepper`, `zoomInTrackWidthsSelectedAction`, `zoomInTrackWidthsSelected`, `zoomOutTrackWidthsSelectedAction`, `zoomOutTrackWidthsSelected`, `zoomTrackWidthsSelectedStepper`, `addMeterSectionVisibilityObserver`, `addIoSectionVisibilityObserver`, `addSendsSectionVisibilityObserver`, `addClipLauncherSectionVisibilityObserver`, `addDeviceSectionVisibilityObserver`, `addCrossFadeSectionVisibilityObserver`, `toggleMeterSectionVisibility`, `toggleIoSectionVisibility`, `toggleSendsSectionVisibility`, `toggleClipLauncherSectionVisibility`, `toggleDeviceSectionVisibility`, `toggleCrossFadeSectionVisibility` | +| `ModulationSource` | 1 | `isMapping`, `addIsMappingObserver`, `toggleIsMapping`, `addNameObserver`, `isMapped`, `addIsMappedObserver` | +| `MultiSampleBrowsingSession` | 0 | `getFileTypeFilter` | +| `MusicBrowsingSession` | 0 | `getFileTypeFilter` | +| `NoteStep` | 46 | `isIsSelected` | +| `Parameter` | 3 | `addNameObserver`, `addValueDisplayObserver`, `reset`, `touch`, `setIndication`, `setLabel`, `restoreAutomationControl`, `hasAutomation`, `deleteAllAutomation` | +| `ParameterBank` | 2 | `setHardwareLayout` | +| `PinnableCursor` | 0 | `isPinned` | +| `PlayingNote` | 2 | — | +| `PlayingNoteArrayValue` | 0 | `isNotePlaying` | +| `PopupBrowser` | 15 | `canAudition`, `shouldAudition`, `selectNextFile`, `selectNextFileAction`, `selectPreviousFile`, `selectPreviousFileAction`, `selectFirstFile`, `selectLastFile`, `cancelAction`, `commitAction` | +| `PresetBrowsingSession` | 0 | `getCategoryFilter`, `getPresetTypeFilter`, `getFileTypeFilter` | +| `PrimaryDevice` | 0 | `switchToDevice`, `addCanSwitchToDeviceObserver` | +| `Project` | 10 | `getRootTrackGroup`, `getShownTopLevelTrackGroup`, `createSceneFromPlayingLauncherClips` | +| `RangedValue` | 5 | `getAsDouble` | +| `RemoteControl` | 2 | `isBeingMapped` | +| `RemoteControlsPage` | 3 | — | +| `SampleBrowsingSession` | 0 | `getFileTypeFilter` | +| `Scene` | 6 | `addClipCountObserver`, `addPositionObserver`, `addIsSelectedInEditorObserver` | +| `SceneBank` | 0 | `getScene`, `scrollPageUp`, `scrollPageDown`, `scrollUp`, `scrollDown`, `scrollTo`, `addScrollPositionObserver`, `addCanScrollUpObserver`, `addCanScrollDownObserver`, `addSceneCountObserver`, `launchScene`, `setIndication` | +| `Scrollable` | 1 | `scrollIntoView`, `scrollBy`, `scrollForwards`, `scrollForwardsAction`, `scrollBackwards`, `scrollBackwardsAction`, `scrollByPages`, `scrollPageForwards`, `scrollPageForwardsAction`, `scrollPageBackwards`, `scrollPageBackwardsAction`, `canScrollBackwards`, `canScrollForwards` | +| `Send` | 1 | `sendChannelColor`, `isPreFader`, `sendMode` | +| `SettableBeatTimeValue` | 1 | `incRaw`, `beatStepper` | +| `SettableBooleanValue` | 1 | `toggle`, `toggleAction`, `setToTrueAction`, `setToFalseAction` | +| `SettableColorValue` | 3 | — | +| `SettableDoubleValue` | 1 | `inc` | +| `SettableEnumValue` | 1 | — | +| `SettableIntegerValue` | 1 | `inc` | +| `SettableRangedValue` | 4 | `inc`, `inc`, `incRaw`, `addBinding`, `addBindingWithRange`, `addBinding`, `addBindingWithRange`, `addBindingWithRangeAndSensitivity`, `addBindingWithSensitivity`, `addBindingWithSensitivity`, `addBinding` | +| `SettableStringArrayValue` | 1 | — | +| `SettableStringValue` | 1 | — | +| `SoloValue` | 0 | `toggle`, `toggleUsingPreferences` | +| `SourceSelector` | 0 | `getHasNoteInputSelected`, `hasNoteInputSelected`, `getHasAudioInputSelected`, `hasAudioInputSelected` | +| `SpecificBitwigDevice` | 0 | `createParameter`, `createIntegerOutputValue` | +| `SpecificPluginDevice` | 0 | `createParameter` | +| `Subscribable` | 0 | `isSubscribed`, `setIsSubscribed`, `subscribe`, `unsubscribe` | +| `TimeSignatureValue` | 5 | `getNumerator`, `getDenominator`, `getTicks`, `ticks` | +| `TimelineEditor` | 0 | `zoomInAction`, `zoomIn`, `zoomOutAction`, `zoomOut`, `zoomLevel`, `zoomToFitAction`, `zoomToFit`, `zoomToSelectionAction`, `zoomToSelection`, `zoomToFitSelectionOrAllAction`, `zoomToFitSelectionOrAll`, `zoomToFitSelectionOrPreviousAction`, `zoomToFitSelectionOrPrevious` | +| `Track` | 25 | `addPositionObserver`, `getClipLauncherSlots`, `getClipLauncher`, `addIsQueuedForStopObserver`, `getArm`, `getMonitor`, `monitor`, `getAutoMonitor`, `autoMonitor`, `getCrossFadeMode`, `isStopped`, `getIsMatrixStopped`, `getIsMatrixQueuedForStop`, `isQueuedForStop`, `getSourceSelector`, `sourceSelector`, `stopAction`, `stopAlt`, `stopAltAction`, `addPitchNamesObserver`, `playNote`, `addTrackTypeObserver`, `getIsPreFader`, `addIsGroupObserver`, `getCanHoldNoteData`, `canHoldNoteData`, `getCanHoldAudioData`, `canHoldAudioData`, `getPrimaryDevice`, `getPrimaryInstrument`, `createMainTrackBank`, `createEffectTrackBank`, `createEffectTrackBank`, `createSiblingsTrackBank`, `beforeTrackInsertionPoint`, `addNoteSource`, `removeNoteSource`, `createNewLauncherClip`, `createNewLauncherClip`, `recordNewLauncherClip`, `launchLastClipWithOptions`, `launchLastClipWithOptionsAction` | +| `TrackBank` | 1 | `getChannel`, `setTrackScrollStepSize`, `scrollTracksPageUp`, `scrollTracksPageDown`, `scrollTracksUp`, `scrollTracksDown`, `scrollToTrack`, `addTrackScrollPositionObserver`, `sceneBank`, `scrollScenesPageUp`, `scrollScenesPageDown`, `scrollScenesUp`, `scrollScenesDown`, `scrollToScene`, `addSceneScrollPositionObserver`, `addCanScrollTracksUpObserver`, `addCanScrollTracksDownObserver`, `addCanScrollScenesUpObserver`, `addCanScrollScenesDownObserver`, `addSceneCountObserver`, `getClipLauncherScenes`, `launchScene`, `followCursorTrack`, `setShouldShowClipLauncherFeedback`, `getChannel` | +| `Transport` | 28 | `playAction`, `continuePlaybackAction`, `stopAction`, `togglePlay`, `restart`, `restartAction`, `recordAction`, `rewindAction`, `fastForwardAction`, `tapTempoAction`, `addIsPlayingObserver`, `addIsRecordingObserver`, `addOverdubObserver`, `addLauncherOverdubObserver`, `addAutomationWriteModeObserver`, `isArrangerAutomationWriteEnabled`, `addIsWritingArrangerAutomationObserver`, `isClipLauncherAutomationWriteEnabled`, `addIsWritingClipLauncherAutomationObserver`, `addAutomationOverrideObserver`, `addIsLoopActiveObserver`, `addPunchInObserver`, `addPunchOutObserver`, `addClickObserver`, `isMetronomeTickPlaybackEnabled`, `addMetronomeTicksObserver`, `addMetronomeVolumeObserver`, `isMetronomeAudibleDuringPreRoll`, `addPreRollClickObserver`, `addPreRollObserver`, `toggleLoop`, `setLoop`, `togglePunchIn`, `togglePunchOut`, `toggleClick`, `setClick`, `toggleMetronomeTicks`, `toggleMetronomeDuringPreRoll`, `setPreRoll`, `setMetronomeValue`, `toggleOverdub`, `setOverdub`, `toggleLauncherOverdub`, `setLauncherOverdub`, `setAutomationWriteMode`, `toggleLatchAutomationWriteMode`, `toggleWriteArrangerAutomation`, `toggleWriteClipLauncherAutomation`, `resetAutomationOverrides`, `getTempo`, `increaseTempo`, `playPosition`, `playStartPosition`, `playStartPositionInSeconds`, `launchFromPlayStartPosition`, `launchFromPlayStartPositionAction`, `jumpToPlayStartPosition`, `jumpToPlayStartPositionAction`, `jumpToPreviousCueMarker`, `jumpToPreviousCueMarkerAction`, `jumpToNextCueMarker`, `jumpToNextCueMarkerAction`, `setPosition`, `incPosition`, `getInPosition`, `getOutPosition`, `addCueMarkerAtPlaybackPosition`, `addCueMarkerAtPlaybackPositionAction`, `getCrossfade`, `crossfade`, `getTimeSignature`, `addClipLauncherPostRecordingActionObserver`, `setClipLauncherPostRecordingAction`, `getClipLauncherPostRecordingTimeOffset`, `isFillModeActive` | +| `UserControlBank` | 0 | `getControl` | +| `Value` | 2 | — | + +## Hardware, controller-surface and settings interfaces (out of scope for MCP) + +`AbsoluteHardwarControlBindable`, `AbsoluteHardwareControl`, `AbsoluteHardwareControlBinding`, `AbsoluteHardwareKnob`, `AbsoluteHardwareValueMatcher`, `BooleanHardwareProperty`, `BooleanValue`, `ColorHardwareProperty`, `ColorValue`, `ContinuousHardwareControl`, `ContinuousHardwareValueMatcher`, `DocumentState`, `HardwareAction`, `HardwareActionBindable`, `HardwareActionBinding`, `HardwareActionMatcher`, `HardwareBindable`, `HardwareBinding`, `HardwareBindingSource`, `HardwareBindingWithRange`, `HardwareBindingWithSensitivity`, `HardwareButton`, `HardwareControl`, `HardwareControlType`, `HardwareDevice`, `HardwareElement`, `HardwareInputMatcher`, `HardwareLight`, `HardwareLightVisualState`, `HardwareOutputElement`, `HardwarePixelDisplay`, `HardwareProperty`, `HardwareSlider`, `HardwareSurface`, `HardwareTextDisplay`, `HardwareTextDisplayLine`, `InputPipe`, `IntegerHardwareProperty`, `IntegerValue`, `InternalHardwareLightState`, `MidiExpressions`, `MidiIn`, `MidiOut`, `MultiStateHardwareLight`, `NoteInput`, `NoteLatch`, `NotificationSettings`, `ObjectArrayValue`, `ObjectHardwareProperty`, `ObjectProxy`, `OnOffHardwareLight`, `OutputPipe`, `PianoKeyboard`, `Pipe`, `Preferences`, `RelativeHardwarControlBindable`, `RelativeHardwareControl`, `RelativeHardwareControlBinding`, `RelativeHardwareControlToRangedValueBinding`, `RelativeHardwareKnob`, `RelativeHardwareValueMatcher`, `RelativePosition`, `RemoteConnection`, `RemoteSocket`, `Setting`, `Settings`, `Signal`, `StringArrayValue`, `StringHardwareProperty`, `StringValue`, `UsbDevice`, `UsbInputPipe`, `UsbInterface`, `UsbOutputPipe`, `UsbPipe`, `UsbTransferDirection`, `UsbTransferStatus`, `UsbTransferType` diff --git a/scripts/api_coverage.py b/scripts/api_coverage.py new file mode 100644 index 00000000..37c7e071 --- /dev/null +++ b/scripts/api_coverage.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Report which Bitwig Controller API methods the extension already calls. + +Lists every public method of every interface in com.bitwig.extension.controller.api (via javap on +the extension-api jar Gradle downloaded) and marks a method "used" when its name is called anywhere +in src/main/java. Name matching is approximate (overloads and same-named methods on other interfaces +count as used), so treat it as a map of what is untouched, not a proof of coverage. + +usage: python3 scripts/api_coverage.py [--markdown] (run from the repo root after ./gradlew build) +""" +import glob +import os +import re +import subprocess +import sys + +# Interfaces that drive physical controllers, USB/OSC devices or the extension's own UI settings. +# They have no use for an MCP agent and are reported separately. +HARDWARE = re.compile( + r"^(Hardware|Absolute|Relative|Continuous|MultiState|OnOff|InternalHardware|Usb|Midi(In|Out)|NoteInput|" + r"RemoteSocket|RemoteConnection|Osc|Pipe|InputPipe|OutputPipe|Boolean|Color|Integer|Object|String)" + r".*(Hardware|Binding|Matcher|Light|Display|Surface|Control|Pipe|Property|Bindable|Knob|Slider|Button|Element|" + r"Interface|Transfer|Socket|Connection|Expressions|Latch|Input)?$" +) +HARDWARE_EXTRA = {"PianoKeyboard", "UsbDevice", "MidiExpressions", "NoteLatch", "Signal", "Setting", "Settings", + "Preferences", "DocumentState", "NotificationSettings", "HardwareDevice", "AudioHardwareIoInfo", + "AudioIoDeviceMatcher", "MasterRecorder"} + + +def api_jar(): + jars = glob.glob(os.path.expanduser("~/.gradle/caches/modules-2/files-2.1/com.bitwig/extension-api/*/*/*.jar")) + jars = [j for j in jars if not j.endswith("-sources.jar") and not j.endswith("-javadoc.jar")] + if not jars: + sys.exit("extension-api jar not found; run ./gradlew build first") + return sorted(jars)[-1] + + +def api_methods(jar): + names = subprocess.run(["unzip", "-Z1", jar], capture_output=True, text=True, check=True).stdout.split() + classes = sorted(n[:-6].replace("/", ".") for n in names + if n.startswith("com/bitwig/extension/controller/api/") and n.endswith(".class") and "$" not in n) + out = subprocess.run(["javap", "-cp", jar, *classes], capture_output=True, text=True, check=True).stdout + api, current = {}, None + for line in out.splitlines(): + m = re.match(r"public (?:interface|abstract class|class|final class) com\.bitwig\.extension\.controller\.api\.(\w+)", line) + if m: + current = m.group(1) + api[current] = [] + continue + m = re.match(r"\s+public (?:abstract |default |static )*[\w.<>\[\], ?]+ (\w+)\((.*)\);", line) + if m and current and m.group(1) not in ("values", "valueOf"): + api[current].append(f"{m.group(1)}({m.group(2).replace('java.lang.', '').replace('com.bitwig.extension.controller.api.', '').replace('com.bitwig.extension.callback.', '')})") + return api + + +def used_names(root="src/main/java"): + names = set() + for path in glob.glob(f"{root}/**/*.java", recursive=True): + names.update(re.findall(r"\.(\w+)\(", open(path, encoding="utf-8").read())) + return names + + +def main(): + api = api_methods(api_jar()) + used = used_names() + rows = [] + for iface, methods in sorted(api.items()): + hw = bool(HARDWARE.match(iface)) and iface not in ("Channel", "ChannelBank") or iface in HARDWARE_EXTRA + done = [m for m in methods if m.split("(")[0] in used] + todo = [m for m in methods if m.split("(")[0] not in used] + rows.append((iface, hw, done, todo)) + total = sum(len(d) + len(t) for i, hw, d, t in rows if not hw) + covered = sum(len(d) for i, hw, d, t in rows if not hw) + print(f"# Bitwig Controller API coverage\n") + print(f"Agent-relevant interfaces: {covered} of {total} methods referenced by name " + f"({100 * covered // max(total, 1)}%). Generated by `scripts/api_coverage.py`.\n") + print("| Interface | Used | Not yet used |\n|---|---|---|") + for iface, hw, done, todo in rows: + if hw or not (done or todo): + continue + print(f"| `{iface}` | {len(done)} | {', '.join('`' + t.split('(')[0] + '`' for t in todo) or '—'} |") + print("\n## Hardware, controller-surface and settings interfaces (out of scope for MCP)\n") + print(", ".join(f"`{i}`" for i, hw, d, t in rows if hw)) + + +if __name__ == "__main__": + main() diff --git a/scripts/device_catalog.py b/scripts/device_catalog.py new file mode 100644 index 00000000..1b32c0d3 --- /dev/null +++ b/scripts/device_catalog.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +"""Build a catalog of every Bitwig device and plugin installed on this machine. + +Two steps: + + inventory What is installed, read from disk only: Bitwig's built-in devices (names and UUIDs the + extension can insert) and every CLAP plugin's descriptor (id, name, vendor, description, + feature tags), read from the plugin files themselves. LV2 plugins are listed for Carla. + scan What each device's controls are and what their values mean, measured in a running + Bitwig through the WigAI MCP server: every remote-control page, each control's display + at 0/25/50/75/100%, the options of switch-like controls, and the chains inside devices. + Needs an EMPTY project open; it inserts and deletes devices on a scratch track. + docs Copy each Bitwig device's description from Bitwig's online user guide into the local catalog. + render Write the catalog as Markdown. + +The catalog lives in ~/.local/share/wigai/ (override with WIGAI_CATALOG_DIR). The extension's +describe_device / list_installed_devices tools read it from there. + +usage: + python3 scripts/device_catalog.py inventory + python3 scripts/device_catalog.py scan [--only NAME_SUBSTRING] [--kind bitwig|clap] [--rescan] + python3 scripts/device_catalog.py docs (copy Bitwig's device descriptions from its online user guide) + python3 scripts/device_catalog.py render + python3 scripts/device_catalog.py update-resource (maintainers: refresh src/main/resources/bitwig-devices.txt) +""" +import argparse +import ctypes +import glob +import json +import os +import re +import struct +import subprocess +import sys +import time +import urllib.request + +CATALOG_DIR = os.environ.get("WIGAI_CATALOG_DIR", os.path.expanduser("~/.local/share/wigai")) +CATALOG = os.path.join(CATALOG_DIR, "device-catalog.json") +MARKDOWN = os.path.join(CATALOG_DIR, "device-catalog.md") +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BITWIG_DEVICES = os.path.join(REPO, "src/main/resources/bitwig-devices.txt") +BITWIG_ROOT = os.environ.get("BITWIG_ROOT", "/opt/bitwig-studio") +CLAP_DIRS = ["/usr/lib/clap", "/usr/local/lib/clap", os.path.expanduser("~/.clap")] +LV2_DIRS = ["/usr/lib/lv2", "/usr/local/lib/lv2", os.path.expanduser("~/.lv2")] +SWEEP = [0.0, 0.25, 0.5, 0.75, 1.0] +ENUM_SWEEP = [i / 16 for i in range(17)] +SCRATCH_TRACK = "wigai catalog scan" + + +# ---------------------------------------------------------------- catalog file + +def load(): + if os.path.exists(CATALOG): + with open(CATALOG, encoding="utf-8") as f: + return json.load(f) + return {"bitwig": {}, "clap": {}, "lv2": {}} + + +def save(cat): + os.makedirs(CATALOG_DIR, exist_ok=True) + tmp = CATALOG + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(cat, f, indent=1, sort_keys=True) + os.replace(tmp, CATALOG) + + +# ---------------------------------------------------------------- inventory + +class ClapVersion(ctypes.Structure): + _fields_ = [("major", ctypes.c_uint32), ("minor", ctypes.c_uint32), ("revision", ctypes.c_uint32)] + + +class ClapDescriptor(ctypes.Structure): + _fields_ = [("clap_version", ClapVersion)] + [(n, ctypes.c_char_p) for n in + ("id", "name", "vendor", "url", "manual_url", "support_url", "version", "description")] + \ + [("features", ctypes.POINTER(ctypes.c_char_p))] + + +class ClapFactory(ctypes.Structure): + pass + + +ClapFactory._fields_ = [ + ("get_plugin_count", ctypes.CFUNCTYPE(ctypes.c_uint32, ctypes.POINTER(ClapFactory))), + ("get_plugin_descriptor", ctypes.CFUNCTYPE(ctypes.POINTER(ClapDescriptor), ctypes.POINTER(ClapFactory), ctypes.c_uint32)), +] + + +class ClapEntry(ctypes.Structure): + _fields_ = [("clap_version", ClapVersion), + ("init", ctypes.CFUNCTYPE(ctypes.c_bool, ctypes.c_char_p)), + ("deinit", ctypes.CFUNCTYPE(None)), + ("get_factory", ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_char_p))] + + +def clap_descriptors(path): + """Runs in a child process (see read_clap) so a plugin that crashes on load cannot take us down.""" + lib = ctypes.CDLL(path, mode=ctypes.RTLD_LOCAL) + entry = ClapEntry.in_dll(lib, "clap_entry") + if not entry.init(path.encode()): + return [] + try: + ptr = entry.get_factory(b"clap.plugin-factory") + if not ptr: + return [] + factory = ctypes.cast(ptr, ctypes.POINTER(ClapFactory)) + out = [] + for i in range(factory.contents.get_plugin_count(factory)): + d = factory.contents.get_plugin_descriptor(factory, i).contents + feats, j = [], 0 + while d.features and d.features[j]: + feats.append(d.features[j].decode(errors="replace")) + j += 1 + s = lambda b: b.decode(errors="replace") if b else "" # noqa: E731 + out.append({"id": s(d.id), "name": s(d.name), "vendor": s(d.vendor), "version": s(d.version), + "description": s(d.description), "url": s(d.url), "manual_url": s(d.manual_url), + "features": feats, "path": path}) + return out + finally: + entry.deinit() + + +def read_clap(path): + code = ("import sys, json; sys.path.insert(0, %r); import device_catalog as d; " + "print(json.dumps(d.clap_descriptors(%r)))") % (os.path.dirname(os.path.abspath(__file__)), path) + try: + r = subprocess.run([sys.executable, "-I", "-c", code], capture_output=True, text=True, timeout=60) + lines = [l for l in r.stdout.splitlines() if l.startswith("[")] + return json.loads(lines[-1]) if lines else [] + except Exception as e: # noqa: BLE001 + print(f" could not read {path}: {e}", file=sys.stderr) + return [] + + +def lv2_plugins(): + out = {} + for base in LV2_DIRS: + for bundle in sorted(glob.glob(os.path.join(base, "*.lv2"))): + for ttl in glob.glob(os.path.join(bundle, "*.ttl")): + text = open(ttl, encoding="utf-8", errors="replace").read() + if "lv2:Plugin" not in text and "a lv2:Plugin" not in text: + continue + for uri, body in re.findall(r"<([^>]+)>\s*\n?\s*a\s+[^;]*lv2:\w*Plugin(.*?)\s\.\s*$", text, re.S | re.M): + name = re.search(r'doap:name\s+"([^"]+)"', body) + comment = re.search(r'rdfs:comment\s+"""(.*?)"""|rdfs:comment\s+"([^"]*)"', body, re.S) + out[uri] = {"uri": uri, "name": name.group(1) if name else os.path.basename(bundle)[:-4], + "description": (comment.group(1) or comment.group(2)).strip() if comment else "", + "bundle": bundle} + return out + + +def library_devices(root=BITWIG_ROOT): + """Bitwig's own devices, from the metadata header of each default preset in its library: + a length-prefixed key followed by 0x08 and a length-prefixed UTF-8 value.""" + out = {} + for path in glob.glob(os.path.join(root, "Library/device-settings/*/Default.bwpreset")): + with open(path, "rb") as f: + head = f.read(4096) + meta = {} + for key in ("device_id", "device_name", "device_type", "device_category"): + k = key.encode() + i = head.find(struct.pack(">I", len(k)) + k) + j = i + 4 + len(k) + if i >= 0 and head[j] == 0x08: + n = struct.unpack(">I", head[j + 1:j + 5])[0] + meta[key] = head[j + 5:j + 5 + n].decode("utf-8", "replace") + if meta.get("device_id") and meta.get("device_name"): + out[meta["device_name"]] = meta + return out + + +TYPES = {"audio_to_audio": "audio effect", "note_to_audio": "instrument", "note_to_note": "note effect"} + + +def update_resource(): + """Regenerates the bundled UUID list the extension falls back to when Bitwig's library is unreadable.""" + devices = library_devices() + with open(BITWIG_DEVICES, "w", encoding="utf-8") as f: + f.write("# Bitwig built-in devices (Name$uuid), generated from Bitwig Studio's Library/device-settings by\n" + "# scripts/device_catalog.py update-resource. The extension also reads that library at startup.\n") + for name in sorted(devices, key=str.lower): + f.write(f"{name}${devices[name]['device_id']}\n") + print(f"wrote {len(devices)} devices to {BITWIG_DEVICES}") + + +def inventory(): + cat = load() + for name, meta in sorted(library_devices().items()): + e = cat["bitwig"].setdefault(name, {}) + e.update({"name": name, "uuid": meta["device_id"], "insertable": True, + "type": TYPES.get(meta.get("device_type"), meta.get("device_type")), + "category": meta.get("device_category", "")}) + for base in CLAP_DIRS: + for path in sorted(glob.glob(os.path.join(base, "*.clap"))): + for d in read_clap(path): + cat["clap"].setdefault(d["id"], {}).update(d) + cat["lv2"] = lv2_plugins() + cat["inventory_time"] = time.strftime("%Y-%m-%d %H:%M:%S") + save(cat) + print(f"bitwig devices: {len(cat['bitwig'])} ({sum(1 for e in cat['bitwig'].values() if e['insertable'])} insertable), " + f"clap plugins: {len(cat['clap'])}, lv2 plugins (Carla): {len(cat['lv2'])}") + print("written:", CATALOG) + + +# ---------------------------------------------------------------- Bitwig's own device descriptions + +USERGUIDE = "https://www.bitwig.com/userguide/latest/" +STOP_AT = {"modulator", "modulators", "grid_modules", "the_grid", "legacy_devices", "credits"} + + +def html_text(fragment): + import html + return html.unescape(re.sub(r"\s+", " ", re.sub("<[^>]+>", " ", fragment))).strip() + + +def fetch_docs(): + """Copies each device's description from Bitwig's online user guide into the local catalog. + + The text is Bitwig's; it is fetched onto the user's machine for reference and not redistributed.""" + cat = load() + index = urllib.request.urlopen(USERGUIDE + "device_descriptions", timeout=60).read().decode("utf-8", "replace") + links = re.findall(r'href="/userguide/latest/([^"#]+)"', index) + pages = [] + for link in links[links.index("device_descriptions") + 1:]: + if link in STOP_AT: + break + if link not in pages: + pages.append(link) + found = 0 + for page in pages: + text = urllib.request.urlopen(USERGUIDE + page, timeout=60).read().decode("utf-8", "replace") + body = text[text.find("]*>", body)[1:]: + name = re.sub(r"^[\d.\s\xa0]+", "", html_text(part.split("")[0])) + paras = [html_text(x) for x in re.findall(r"]*>(.*?)

", part, re.S)] + paras = [x for x in paras if x and "Was this helpful" not in x] + if name in cat["bitwig"] and paras: + cat["bitwig"][name]["bitwig_docs"] = {"text": " ".join(paras[:6])[:1500], "url": USERGUIDE + page} + found += 1 + time.sleep(0.3) + cat["docs_time"] = time.strftime("%Y-%m-%d %H:%M:%S") + save(cat) + missing = [n for n, e in cat["bitwig"].items() if "bitwig_docs" not in e] + print(f"descriptions for {found} Bitwig devices from {len(pages)} user-guide pages; without one: {', '.join(missing) or 'none'}") + + +# ---------------------------------------------------------------- live scan + +class Mcp: + def __init__(self, url): + self.url, self.session, self.n = url, None, 0 + self.call("initialize", {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "device-catalog", "version": "1"}}) + self.post({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + def post(self, body): + req = urllib.request.Request(self.url, json.dumps(body).encode(), method="POST", headers={ + "Content-Type": "application/json", "Accept": "application/json, text/event-stream", + **({"mcp-session-id": self.session} if self.session else {})}) + with urllib.request.urlopen(req, timeout=120) as r: + self.session = r.headers.get("mcp-session-id") or self.session + text = r.read().decode() + data = [l[6:] for l in text.splitlines() if l.startswith("data: ")] + return json.loads(data[0]) if data else (json.loads(text) if text.strip() else None) + + def call(self, method, params): + self.n += 1 + r = self.post({"jsonrpc": "2.0", "id": self.n, "method": method, "params": params}) + if "error" in r: + raise RuntimeError(r["error"]) + return r["result"] + + def tool(self, tool_name, **args): + res = self.call("tools/call", {"name": tool_name, "arguments": args}) + body = json.loads(res["content"][0]["text"]) + if body.get("status") != "success": + raise RuntimeError(f"{tool_name}: {body.get('error', body)}") + return body["data"] + + +NUM = re.compile(r"^\s*([+\-−]?\d+(?:\.\d+)?)\s*(.*)$") + + +def numeric(display): + m = NUM.match(display or "") + return (float(m.group(1).replace("−", "-")), m.group(2).strip()) if m else None + + +def describe_control(samples): + """samples: [(value, display)] -> summary of range, unit and options.""" + displays = [d for _, d in samples] + nums = [numeric(d) for d in displays] + if all(n is not None for n in nums): + units = sorted({u for _, u in nums if u}) + return {"kind": "continuous", "min": displays[0], "max": displays[-1], + "curve": [d for d in displays], "units": units} + options = [] + for d in displays: + if d not in options: + options.append(d) + return {"kind": "options", "options": options} + + +def scan_device(m, track, kind, key, entry, rescan): + if entry.get("scan") and "error" not in entry["scan"] and not rescan: + return False + args = {"track_index": track} + if kind == "bitwig": + args["bitwig_device"] = entry["name"] + else: + args["clap_id"] = key + r = m.tool("insert_device", position="end", **args) + if not r["inserted"]: + entry["scan"] = {"error": "Bitwig did not insert it", "time": time.strftime("%Y-%m-%d %H:%M:%S")} + return True + devices = m.tool("list_devices_on_track", track_index=track) + di = len(devices) - 1 + entry["device_type"] = devices[di]["type"] + scan = {"time": time.strftime("%Y-%m-%d %H:%M:%S"), "pages": []} + try: + try: + chains = m.tool("get_device_chains", track_index=track, device_index=di)["chains"] + scan["chains"] = [{"chain": c["chain"], "default_devices": [d["name"] for d in c["devices"]]} for c in chains] + except RuntimeError: + scan["chains"] = [] + base = m.tool("get_device_parameters", track_index=track, device_index=di) + deadline = time.time() + (4 if kind == "clap" else 0) + while not base["page_names"] and time.time() < deadline: # plugins publish their pages after loading + time.sleep(0.5) + base = m.tool("get_device_parameters", track_index=track, device_index=di) + scan["page_names"] = base["page_names"] + controls = [(pg["page"], p["index"], p["name"], p["value"], p["display"]) + for pg in base["pages"] for p in pg["parameters"]] + samples = {(pg, ix): [] for pg, ix, *_ in controls} + if not controls: + scan["note"] = ("Bitwig exposes no remote-control pages for this device, so its parameters cannot be " + "reached through the API (plugins only get pages when they define them).") + entry["scan"] = scan + return True + for x in SWEEP: + m.tool("set_device_parameters", track_index=track, device_index=di, verify=False, + parameters=[{"page": pg, "index": ix, "value": x} for pg, ix, *_ in controls]) + now = m.tool("get_device_parameters", track_index=track, device_index=di) + for pg in now["pages"]: + for p in pg["parameters"]: + if (pg["page"], p["index"]) in samples: + samples[(pg["page"], p["index"])].append((x, p["display"])) + pages_with_options = {pg for (pg, ix), s in samples.items() if describe_control(s)["kind"] == "options"} + for pg_index in sorted(pages_with_options): + page_controls = [(pg, ix) for pg, ix, *_ in controls if pg == pg_index + and describe_control(samples[(pg, ix)])["kind"] == "options"] + dense = {k: [] for k in page_controls} + for x in ENUM_SWEEP: + m.tool("set_device_parameters", track_index=track, device_index=di, verify=False, + parameters=[{"page": pg, "index": ix, "value": x} for pg, ix in page_controls]) + now = m.tool("get_device_parameters", track_index=track, device_index=di, page=pg_index) + for p in now["pages"][0]["parameters"]: + if (pg_index, p["index"]) in dense: + dense[(pg_index, p["index"])].append((x, p["display"])) + samples.update(dense) + by_page = {} + seen = set() + for pg, ix, name, value, display in controls: + c = {"index": ix, "name": name, "default_value": round(value, 4), "default": display} + c.update(describe_control(samples[(pg, ix)])) + c["also_on_other_page"] = name in seen + seen.add(name) + by_page.setdefault(pg, []).append(c) + scan["pages"] = [{"page": pg, "page_name": scan["page_names"][pg] if pg < len(scan["page_names"]) else "", + "controls": by_page[pg]} for pg in sorted(by_page)] + finally: + m.tool("delete_device", track_index=track, device_index=di) + entry["scan"] = scan + return True + + +def scan(args): + cat = load() + if not cat["bitwig"] and not cat["clap"]: + sys.exit("run 'inventory' first") + m = Mcp(args.url) + tracks = [t for t in m.tool("list_tracks") if t["type"] not in ("master", "effect")] + scratch = [t for t in tracks if t["name"] == SCRATCH_TRACK] + if not scratch and len(tracks) > 2 and not args.force: + sys.exit(f"Refusing to scan: the open project has {len(tracks)} tracks. Open an empty project (File > New).") + if scratch: + track = scratch[0]["index"] + else: + track = m.tool("create_track", type="instrument", name=SCRATCH_TRACK)["track_index"] + for existing in reversed(m.tool("list_devices_on_track", track_index=track)): + m.tool("delete_device", track_index=track, device_index=existing["index"]) + jobs = [] + if args.kind in (None, "bitwig"): + jobs += [("bitwig", k, e) for k, e in sorted(cat["bitwig"].items()) if e.get("insertable")] + if args.kind in (None, "clap"): + jobs += [("clap", k, e) for k, e in sorted(cat["clap"].items())] + if args.only: + jobs = [j for j in jobs if args.only.lower() in (j[2].get("name") or j[1]).lower()] + start = time.time() + for n, (kind, key, entry) in enumerate(jobs, 1): + label = entry.get("name") or key + t0 = time.time() + try: + did = scan_device(m, track, kind, key, entry, args.rescan) + except Exception as e: # noqa: BLE001 + entry["scan"] = {"error": str(e)[:500], "time": time.strftime("%Y-%m-%d %H:%M:%S")} + did = True + for existing in reversed(m.tool("list_devices_on_track", track_index=track)): + m.tool("delete_device", track_index=track, device_index=existing["index"]) + if did: + save(cat) + status = "error: " + entry["scan"]["error"][:80] if "error" in entry["scan"] else \ + f"{sum(len(p['controls']) for p in entry['scan']['pages'])} controls" + print(f"[{n}/{len(jobs)}] {kind} {label}: {status} ({time.time() - t0:.0f}s, {(time.time() - start) / 60:.0f} min total)", + flush=True) + m.tool("delete_track", track_index=track) + print("done:", CATALOG) + + +# ---------------------------------------------------------------- markdown + +def render(): + cat = load() + out = ["# Installed devices and plugins", "", + f"Generated by `scripts/device_catalog.py` (inventory {cat.get('inventory_time', '?')}). " + "Values are what Bitwig displays for each control at 0/25/50/75/100% of its range.", ""] + + def section(title, entries, header_fn): + out.extend([f"## {title}", ""]) + for key, e in entries: + out.append(f"### {e.get('name') or key}") + out.extend(header_fn(key, e)) + s = e.get("scan") + if not s: + out.extend(["*Not scanned yet.*", ""]) + continue + if "error" in s: + out.extend([f"*Scan failed: {s['error']}*", ""]) + continue + if e.get("device_type"): + out.append(f"Type: {e['device_type']}") + for c in s.get("chains", []): + out.append(f"Chain **{c['chain']}**" + (f" (default: {', '.join(c['default_devices'])})" if c["default_devices"] else "")) + out.append("") + for pg in s["pages"]: + out.append(f"**{pg['page_name'] or 'Page ' + str(pg['page'])}**") + for c in pg["controls"]: + if c.get("also_on_other_page"): + continue + if c["kind"] == "continuous": + out.append(f"- {c['name']}: {c['min']} to {c['max']} (default {c['default']}; curve {' / '.join(c['curve'])})") + else: + out.append(f"- {c['name']}: {', '.join(c['options'])} (default {c['default']})") + out.append("") + section("Bitwig devices", sorted((k, e) for k, e in cat["bitwig"].items() if e.get("insertable")), + lambda k, e: []) + section("CLAP plugins", sorted(cat["clap"].items(), key=lambda kv: (kv[1].get("vendor", ""), kv[1].get("name", ""))), + lambda k, e: [f"{e.get('vendor', '')} {e.get('version', '')} · `{k}` · {', '.join(e.get('features', []))}", + "", e.get("description", "") or ""]) + out.extend(["## LV2 plugins (usable through Carla)", ""]) + for uri, e in sorted(cat.get("lv2", {}).items(), key=lambda kv: kv[1]["name"]): + out.append(f"- **{e['name']}** — {e['description'][:200]}" if e["description"] else f"- **{e['name']}**") + os.makedirs(CATALOG_DIR, exist_ok=True) + with open(MARKDOWN, "w", encoding="utf-8") as f: + f.write("\n".join(out) + "\n") + print("written:", MARKDOWN) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + sub.add_parser("inventory") + sc = sub.add_parser("scan") + sc.add_argument("--url", default="http://localhost:61169/mcp") + sc.add_argument("--only") + sc.add_argument("--kind", choices=["bitwig", "clap"]) + sc.add_argument("--rescan", action="store_true") + sc.add_argument("--force", action="store_true") + sub.add_parser("render") + sub.add_parser("update-resource") + sub.add_parser("docs") + a = ap.parse_args() + {"inventory": inventory, "scan": lambda: scan(a), "render": render, "update-resource": update_resource, + "docs": fetch_docs}[a.cmd]() + + +if __name__ == "__main__": + main() diff --git a/scripts/live_test.py b/scripts/live_test.py new file mode 100644 index 00000000..9ecb3563 --- /dev/null +++ b/scripts/live_test.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Live smoke test for the WigAI MCP server against a running Bitwig Studio. + +It creates its own temporary tracks at the end of the track list, exercises the editing tools +on them, and deletes them again. It never touches existing tracks except to read them. +Refuses to run unless the open project is empty (at most 2 tracks); --force overrides. + +usage: python3 scripts/live_test.py [--url http://localhost:61169/mcp] [--keep] [--force] +""" +import argparse +import json +import os +import sys +import tempfile +import urllib.request + +PASS, FAIL = [], [] + + +class Mcp: + def __init__(self, url): + self.url, self.session, self.next_id = url, None, 1 + self.rpc("initialize", {"protocolVersion": "2025-03-26", "capabilities": {}, + "clientInfo": {"name": "wigai-live-test", "version": "1"}}) + self.post({"jsonrpc": "2.0", "method": "notifications/initialized"}) + + def post(self, body): + req = urllib.request.Request(self.url, data=json.dumps(body).encode(), method="POST", headers={ + "Content-Type": "application/json", "Accept": "application/json, text/event-stream", + **({"mcp-session-id": self.session} if self.session else {})}) + with urllib.request.urlopen(req, timeout=60) as r: + self.session = r.headers.get("mcp-session-id") or self.session + text = r.read().decode() + for line in text.splitlines(): # SSE framing + if line.startswith("data: "): + return json.loads(line[6:]) + return json.loads(text) if text.strip() else None + + def rpc(self, method, params): + self.next_id += 1 + resp = self.post({"jsonrpc": "2.0", "id": self.next_id, "method": method, "params": params}) + if "error" in resp: + raise RuntimeError(resp["error"]) + return resp["result"] + + def tool(self, tool_name, **args): + res = self.rpc("tools/call", {"name": tool_name, "arguments": args}) + body = json.loads(res["content"][0]["text"]) + if res.get("isError") or body.get("status") != "success": + raise RuntimeError(f"{tool_name}: {body.get('error', body)}") + return body["data"] + + +def check(label, cond, detail=""): + (PASS if cond else FAIL).append(label) + print((" ok " if cond else " FAIL ") + label + (f" ({detail})" if detail and not cond else "")) + + +def step(label, fn): + try: + return fn() + except Exception as e: # noqa: BLE001 + check(label, False, str(e)) + return None + + +def write_midi(path): + """One-bar C major chord, 480 ppq.""" + def vlq(n): + out = [n & 0x7F] + while n > 0x7F: + n >>= 7 + out.insert(0, (n & 0x7F) | 0x80) + return bytes(out) + ev = b"" + for i, p in enumerate((60, 64, 67)): + ev += vlq(0) + bytes([0x90, p, 100]) + for i, p in enumerate((60, 64, 67)): + ev += vlq(1920 if i == 0 else 0) + bytes([0x80, p, 0]) + ev += vlq(0) + b"\xff\x2f\x00" + with open(path, "wb") as f: + f.write(b"MThd" + (6).to_bytes(4, "big") + (0).to_bytes(2, "big") + (1).to_bytes(2, "big") + (480).to_bytes(2, "big")) + f.write(b"MTrk" + len(ev).to_bytes(4, "big") + ev) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--url", default="http://localhost:61169/mcp") + ap.add_argument("--keep", action="store_true", help="leave the test tracks in place") + ap.add_argument("--force", action="store_true", help="run even if the open project is not empty") + args = ap.parse_args() + m = Mcp(args.url) + + tools = {t["name"] for t in m.rpc("tools/list", {})["tools"]} + check("new tools are registered", {"create_track", "insert_device", "set_device_parameters"} <= tools, + f"missing {sorted({'create_track', 'insert_device', 'set_device_parameters'} - tools)}") + + existing = [t for t in m.tool("list_tracks") if t["type"] not in ("master", "effect")] + if len(existing) > 2 and not args.force: + print(f"Refusing to run: the open project has {len(existing)} tracks. This test creates, edits and deletes " + "tracks and devices; open an empty project (File > New) or pass --force.") + return 2 + n_before = len(existing) + created = [] + + print("tracks") + t = step("create_track instrument", lambda: m.tool("create_track", type="instrument", name="WigAI test inst")) + if t: + created.append("WigAI test inst") + check("create_track instrument", t.get("name") == "WigAI test inst", t) + t = step("create_track audio", lambda: m.tool("create_track", type="audio", name="WigAI test audio")) + if t: + created.append("WigAI test audio") + check("create_track audio", t.get("name") == "WigAI test audio", t) + if not created: + return finish() + + r = step("set_track volume_db/pan/mute", lambda: m.tool("set_track", track_name="WigAI test audio", + volume_db=-3.0, pan=-0.5, mute=True, color="#3366CC")) + if r: + check("volume_db lands on -3 dB", r["volume_display"].replace("−", "-").startswith("-3.0"), r["volume_display"]) + check("pan set", abs(r["pan"] + 0.5) < 0.02, r["pan"]) + check("mute set", r["mute"] is True) + + print("devices") + r = step("list_bitwig_devices", lambda: m.tool("list_bitwig_devices")) + check("list_bitwig_devices has EQ+", bool(r) and any(d.get("name") == "EQ+" for d in r)) + r = step("insert_device EQ+", lambda: m.tool("insert_device", track_name="WigAI test audio", bitwig_device="EQ+")) + check("insert_device EQ+", bool(r) and r["inserted"] and r["devices"][-1]["name"] == "EQ+", r) + r = step("insert_device Reverb after EQ+", lambda: m.tool("insert_device", track_name="WigAI test audio", + bitwig_device="Reverb", after_device_index=0)) + check("insert_device Reverb after EQ+", bool(r) and [d["name"] for d in r["devices"]] == ["EQ+", "Reverb"], r) + + r = step("get_device_parameters all pages", lambda: m.tool("get_device_parameters", track_name="WigAI test audio", device_index=1)) + if r: + names = [p["name"] for pg in r["pages"] for p in pg["parameters"]] + check("get_device_parameters returns pages", len(r["pages"]) >= 1 and len(names) > 0, r["page_names"]) + target = names[0] + s = step("set_device_parameters by name", lambda: m.tool("set_device_parameters", track_name="WigAI test audio", + device_index=1, parameters=[{"name": target, "value": 0.25}])) + check("set_device_parameters by name", bool(s) and s["results"][0]["status"] == "success" + and abs(s["results"][0]["value"] - 0.25) < 0.01, s) + s = step("set_device_parameters by page/index", lambda: m.tool("set_device_parameters", track_name="WigAI test audio", + device_index=1, parameters=[{"page": 0, "index": 0, "value": 0.75}])) + check("set_device_parameters by page/index", bool(s) and abs(s["results"][0]["value"] - 0.75) < 0.01, s) + + r = step("set_device_enabled false", lambda: m.tool("set_device_enabled", track_name="WigAI test audio", device_index=0, enabled=False)) + check("bypass device", bool(r) and r["enabled"] is False, r) + r = step("set_device_enabled true", lambda: m.tool("set_device_enabled", track_name="WigAI test audio", device_index=0, enabled=True)) + check("un-bypass device", bool(r) and r["enabled"] is True, r) + + r = step("copy_device to other track", lambda: m.tool("copy_device", from_track_name="WigAI test audio", from_device_index=0, + to_track_name="WigAI test inst")) + check("copy_device to other track", bool(r) and any(d["name"] == "EQ+" for d in r["target_track_devices"]), r) + r = step("move_device within track", lambda: m.tool("move_device", from_track_name="WigAI test audio", from_device_index=1, + to_track_name="WigAI test audio", position="start")) + check("move_device within track", bool(r) and [d["name"] for d in r["target_track_devices"]] == ["Reverb", "EQ+"], r) + r = step("delete_device", lambda: m.tool("delete_device", track_name="WigAI test audio", device_index=0)) + check("delete_device", bool(r) and [d["name"] for d in r["devices"]] == ["EQ+"], r) + + print("clips") + r = step("create_clip", lambda: m.tool("create_clip", track_name="WigAI test inst", slot_index=0, length_beats=4)) + check("create_clip", bool(r) and r["created"], r) + r = step("write_notes", lambda: m.tool("write_notes", track_name="WigAI test inst", slot_index=0, clear_first=True, + notes=[{"start": 0, "pitch": 60, "duration": 1}, {"start": 1, "pitch": 64, "velocity": 90, "duration": 1}])) + check("write_notes", bool(r) and r["notes_written"] == 2, r) + r = step("delete_clip", lambda: m.tool("delete_clip", track_name="WigAI test inst", slot_index=0)) + check("delete_clip", bool(r) and r["deleted"], r) + + midi = os.path.join(tempfile.mkdtemp(prefix="wigai-"), "chord.mid") + write_midi(midi) + r = step("insert_file midi into slot", lambda: m.tool("insert_file", track_name="WigAI test inst", slot_index=1, path=midi)) + check("insert_file midi into slot", bool(r) and r["inserted"], r) + + print("application") + r = step("list_actions", lambda: m.tool("list_actions", filter="metronome")) + check("list_actions filter", bool(r) and len(r) > 0, r) + st = m.tool("status")["transport"] + r = step("set_transport metronome toggle", lambda: m.tool("set_transport", metronome=not st["metronome_active"])) + check("set_transport metronome", bool(r) and r["metronome"] == (not st["metronome_active"]), r) + m.tool("set_transport", metronome=st["metronome_active"]) + + if not args.keep: + print("cleanup") + for name in created: + step(f"delete_track {name}", lambda name=name: m.tool("delete_track", track_name=name)) + n_after = len([t for t in m.tool("list_tracks") if t["type"] not in ("master", "effect")]) + check("track count restored", n_after == n_before, f"{n_before} -> {n_after}") + return finish() + + +def finish(): + print(f"\n{len(PASS)} passed, {len(FAIL)} failed") + for f in FAIL: + print(" failed:", f) + return 1 if FAIL else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/live_test_container.py b/scripts/live_test_container.py new file mode 100644 index 00000000..72fe8766 --- /dev/null +++ b/scripts/live_test_container.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Live test for container and library tools (drum pads, layer mixers, presets on disk, replace_device, the popup +browser). Runs only against an EMPTY project; creates and deletes its own tracks. The browser opens briefly +in Bitwig's window. + +usage: python3 scripts/live_test_container.py [--url http://localhost:61169/mcp] +""" +import math +import os +import struct +import sys +import tempfile +import time +import wave + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_test import Mcp # noqa: E402 + +url = sys.argv[sys.argv.index("--url") + 1] if "--url" in sys.argv else "http://localhost:61169/mcp" +m = Mcp(url) +P, F = [], [] + + +def ok(tool_name, **a): + return m.tool(tool_name, **a) +def check(label, cond, detail=""): + (P if cond else F).append(label); print(("ok " if cond else "FAIL ") + label + ("" if cond else f" ({detail})"), flush=True) +def index_of(name): + return [x["name"] for x in ok("list_tracks")].index(name) + + +regular = [t for t in ok("list_tracks") if t["type"] not in ("master", "effect")] +if len(regular) > 2: + sys.exit(f"Refusing to run: the open project has {len(regular)} tracks. Open an empty project (File > New).") + +sample = os.path.join(tempfile.mkdtemp(prefix="wigai-"), "blip.wav") +with wave.open(sample, "wb") as w: + w.setnchannels(1); w.setsampwidth(2); w.setframerate(44100) + w.writeframes(b"".join(struct.pack(" 0, r["indexed"]) + kits = r["presets"] + d = index_of("b2 Drums") + if kits: + r = ok("insert_device", track_index=d, preset_file=kits[0]["path"]) + check("insert_device loads a preset file", r["inserted"] and r["devices"][0]["name"] == "Drum Machine", r) + else: + ok("insert_device", track_index=d, bitwig_device="Drum Machine") + + r = ok("insert_into_drum_pad", track_index=d, device_index=0, pad=60, file=sample) + check("insert_into_drum_pad with a sample makes a Sampler", r["inserted"] and r["devices"][0]["name"] == "Sampler", r) + r = ok("insert_into_drum_pad", track_index=d, device_index=0, pad=60, bitwig_device="Bit-8") + check("insert_into_drum_pad appends a device", [x["name"] for x in r["devices"]] == ["Sampler", "Bit-8"], r) + r = ok("set_drum_pad", track_index=d, device_index=0, pad=60, name="Blip", volume_db=-6, pan=-0.5, mute=True) + check("set_drum_pad name/volume/pan/mute", r["name"] == "Blip" and r["volume"] == "-6.0 dB" + and r["pan"].startswith("-50") and r["mute"] is True, r) + ok("set_drum_pad", track_index=d, device_index=0, pad=60, solo=True) + pads = ok("list_drum_pads", track_index=d, device_index=0) + check("list_drum_pads reports mute and solo", pads["has_muted_pads"] and pads["has_soloed_pads"] + and any(p["pad"] == 60 and p["note"] == "C3" for p in pads["pads"]), pads) + r = ok("clear_drum_pads", track_index=d, device_index=0, mutes=True, solos=True) + check("clear_drum_pads", not r["has_muted_pads"] and not r["has_soloed_pads"], r) + + fx = index_of("b2 FX") + ok("insert_device", track_index=fx, bitwig_device="FX Layer") + r = ok("set_layer", track_index=fx, device_index=0, layer=0, name="Wet", volume_db=-3, pan=0.25, mute=True) + check("set_layer name/volume/pan/mute", r["name"] == "Wet" and r["volume"] == "-3.0 dB" and r["mute"] is True, r) + ok("insert_device", track_index=fx, device_index=0, chain="layer:0", bitwig_device="Chorus+") + r = ok("list_layers", track_index=fx, device_index=0) + check("list_layers shows the layer's devices", r["layers"][0]["devices"][0]["name"] == "Chorus+", r) + + ok("insert_device", track_index=fx, bitwig_device="Delay+") + r = ok("replace_device", track_index=fx, device_index=1, bitwig_device="Convolution") + check("replace_device swaps in place", r["replaced"] == "Delay+" and r["device"] == "Convolution", r) + + r = ok("browser_open", track_index=fx, device_index=1, mode="after") + check("browser_open lists content types and results", "Bitwig Presets" in r["content_types"] and r["result_count"] > 0, r) + r = ok("browser_state", list_column="category") + check("browser_state lists a column", "Reverb" in r["category_items"], r.get("category_items")) + r = ok("browser_filter", content_type="Devices", filters={"category": "Reverb"}) + check("browser_filter narrows results", r["filters"]["category"]["selected"] == "Reverb" and "Reverb" in r["results"], r) + r = ok("browser_commit", result="Reverb") + check("browser_commit loads the result", r["loaded"] == "Reverb" and not r["browser_open"], r) + names = [x["name"] for x in ok("list_devices_on_track", track_index=fx)] + check("browser inserted after the device", names == ["FX Layer", "Convolution", "Reverb"], names) + + r = ok("browser_open", track_index=fx, device_index=1, mode="replace") + check("browser_open replace shows the device's presets", r["result_count"] > 0, r) + preset = r["results"][0] + r = ok("browser_commit", result_index=0) + check("browser_commit by index loads a preset", r["loaded"] == preset, r) + ok("browser_open", track_index=fx, mode="end") + check("browser_cancel closes", ok("browser_cancel")["browser_open"] is False) + names = [x["name"] for x in ok("list_devices_on_track", track_index=fx)] + check("cancel loads nothing", names == ["FX Layer", "Convolution", "Reverb"], names) +finally: + ok("browser_cancel") + for name in ("b2 Drums", "b2 FX"): + names = [x["name"] for x in ok("list_tracks")] + while name in names: + ok("delete_track", track_index=names.index(name)); names = [x["name"] for x in ok("list_tracks")] + os.remove(sample) + print("tracks after cleanup:", [x["name"] for x in ok("list_tracks")]) +print(f"\n{len(P)} passed, {len(F)} failed", F) +sys.exit(1 if F else 0) diff --git a/scripts/live_test_events.py b/scripts/live_test_events.py new file mode 100644 index 00000000..c6ad762c --- /dev/null +++ b/scripts/live_test_events.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Live test for the plugin inventory (Bitwig's plugin cache, VST2/VST3 insertion by id) and for following changes +(get_events long-poll, watch streaming notifications). Runs only against an EMPTY project; creates and deletes +its own track. + +usage: python3 scripts/live_test_events.py [--url http://localhost:61169/mcp] +""" +import json +import os +import sys +import threading +import time +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_test import Mcp # noqa: E402 + +url = sys.argv[sys.argv.index("--url") + 1] if "--url" in sys.argv else "http://localhost:61169/mcp" +m = Mcp(url) +P, F = [], [] + + +def ok(tool_name, **a): + return m.tool(tool_name, **a) +def check(label, cond, detail=""): + (P if cond else F).append(label); print(("ok " if cond else "FAIL ") + label + ("" if cond else f" ({detail})"), flush=True) +def index_of(name): + return [x["name"] for x in ok("list_tracks")].index(name) + + +def stream_watch(duration_ms, types): + """Calls watch with a progress token and returns (seconds after start, message) for every SSE message.""" + m.next_id += 1 + body = {"jsonrpc": "2.0", "id": m.next_id, "method": "tools/call", + "params": {"name": "watch", "arguments": {"duration_ms": duration_ms, "types": types}, + "_meta": {"progressToken": "live-test"}}} + req = urllib.request.Request(m.url, data=json.dumps(body).encode(), method="POST", headers={ + "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "mcp-session-id": m.session}) + out, start = [], time.time() + with urllib.request.urlopen(req, timeout=duration_ms / 1000 + 30) as r: + for raw in r: + line = raw.decode().rstrip() + if line.startswith("data: "): + out.append((time.time() - start, json.loads(line[6:]))) + return out + + +regular = [t for t in ok("list_tracks") if t["type"] not in ("master", "effect")] +if len(regular) > 2: + sys.exit(f"Refusing to run: the open project has {len(regular)} tracks. Open an empty project (File > New).") + +ok("create_track", type="audio", name="b5 Events") +try: + counts = {k: ok("list_installed_devices", kind=k)["count"] for k in ("clap", "vst3", "vst2")} + print(" plugins:", counts) + check("plugins come from Bitwig's plugin cache", sum(counts.values()) > 0, counts) + t = index_of("b5 Events") + vst3 = [d for d in ok("list_installed_devices", kind="vst3")["devices"] if "Reverb" in d["name"] or "EQ" in d["type"]] + if vst3: + r = ok("insert_device", track_index=t, vst3_id=vst3[0]["id"]) + check("insert_device by vst3_id from the inventory", r["inserted"], r) + vst2 = [d for d in ok("list_installed_devices", kind="vst2")["devices"] if not d["name"].startswith("Carla")] + if vst2: + r = ok("insert_device", track_index=t, vst2_id=int(vst2[0]["id"])) + check("insert_device by vst2_id from the inventory", r["inserted"], r) + + start = ok("get_events") + check("get_events without since_seq gives a starting point", start["events"] == [] and "next_seq" in start, start) + ok("set_track", track_index=t, mute=True) + r = ok("get_events", since_seq=start["next_seq"], wait_ms=2000, types=["track"]) + check("get_events reports a mute", any(e.get("field") == "mute" and e.get("value") is True for e in r["events"]), r) + + since = r["next_seq"] + threading.Thread(target=lambda: (time.sleep(1.0), m.tool("set_track", track_index=t, mute=False))).start() + t0 = time.time() + r = ok("get_events", since_seq=since, wait_ms=8000, types=["track"]) + waited = time.time() - t0 + check("get_events long-poll returns when the change happens", r["events"] and 0.7 < waited < 4, (waited, r)) + + since = r["next_seq"] + ok("set_engine_active", active=True); time.sleep(0.5) + since = ok("get_events")["next_seq"] + ok("transport_start"); time.sleep(0.6); ok("transport_stop") + seen, deadline = [], time.time() + 4 + while time.time() < deadline and not any(e.get("playing") is False for e in seen): + r = ok("get_events", since_seq=since, wait_ms=1000, types=["transport"]) + seen += r["events"]; since = r["next_seq"] + check("transport events (play then stop)", any(e.get("playing") is True for e in seen) + and any(e.get("playing") is False for e in seen), seen) + + threading.Thread(target=lambda: (time.sleep(1.0), m.tool("set_track", track_index=t, solo=True), + time.sleep(0.6), m.tool("set_track", track_index=t, solo=False))).start() + msgs = stream_watch(3000, ["track"]) + notes = [(s, d) for s, d in msgs if d.get("method") == "notifications/progress"] + logs = [d for _, d in msgs if d.get("method") == "notifications/message"] + final = [d for _, d in msgs if "result" in d] + print(" watch:", [(round(s, 2), d["method"]) for s, d in msgs if "method" in d]) + check("watch streams progress notifications while it runs", len(notes) >= 2 and notes[0][0] < 2.5, notes) + check("watch also sends log messages", len(logs) == len(notes), (len(logs), len(notes))) + body = json.loads(final[0]["result"]["content"][0]["text"])["data"] if final else {} + check("watch returns every change it saw", len(body.get("events", [])) == len(notes), body) +finally: + names = [x["name"] for x in ok("list_tracks")] + while "b5 Events" in names: + ok("delete_track", track_index=names.index("b5 Events")); names = [x["name"] for x in ok("list_tracks")] + print("tracks after cleanup:", names) +print(f"\n{len(P)} passed, {len(F)} failed", F) +sys.exit(1 if F else 0) diff --git a/scripts/live_test_midi.py b/scripts/live_test_midi.py new file mode 100644 index 00000000..5d58be9c --- /dev/null +++ b/scripts/live_test_midi.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Live test for playing notes, raw MIDI, modulation readout and show. Runs only against an EMPTY project; +creates and deletes its own track, which is silenced before anything plays. show switches Bitwig's panel +layout and leaves it on ARRANGE. + +usage: python3 scripts/live_test_midi.py [--url http://localhost:61169/mcp] +""" +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_test import Mcp # noqa: E402 + +url = sys.argv[sys.argv.index("--url") + 1] if "--url" in sys.argv else "http://localhost:61169/mcp" +m = Mcp(url) +P, F = [], [] + + +def ok(tool_name, **a): + return m.tool(tool_name, **a) +def check(label, cond, detail=""): + (P if cond else F).append(label); print(("ok " if cond else "FAIL ") + label + ("" if cond else f" ({detail})"), flush=True) + + +regular = [t for t in ok("list_tracks") if t["type"] not in ("master", "effect")] +if len(regular) > 2: + sys.exit(f"Refusing to run: the open project has {len(regular)} tracks. Open an empty project (File > New).") + +t = ok("create_track", type="instrument", name="b4 MIDI")["track_index"] +try: + ok("set_track", track_index=t, volume=0.0) + ok("insert_device", track_index=t, bitwig_device="Polysynth") + ok("set_engine_active", active=True) + ok("play_notes", track_index=t, notes=[{"pitch": 60, "duration_ms": 1500}, {"pitch": 67, "duration_ms": 1500}]) + time.sleep(0.5) + r = ok("get_playing_notes") + mine = [x for x in r["tracks"] if x["track_index"] == t] + check("get_playing_notes sees the auditioned chord", mine and sorted(n["pitch"] for n in mine[0]["notes"]) == [60, 67], r) + time.sleep(1.5) + r = ok("get_playing_notes") + check("get_playing_notes empties after release", not [x for x in r["tracks"] if x["track_index"] == t], r) + + r = ok("send_midi", track_index=t, type="cc", number=1, value=100) + check("send_midi cc", (r["status"], r["data1"], r["data2"]) == (0xB0, 1, 100), r) + r = ok("send_midi", track_index=t, type="pitch_bend", value=0) + check("send_midi pitch_bend center", (r["status"], r["data1"], r["data2"]) == (0xE0, 0, 64), r) + try: + ok("send_midi", track_index=t, type="cc", number=1, value=300) + check("send_midi rejects out-of-range values", False) + except RuntimeError as e: + check("send_midi rejects out-of-range values", "0-127" in str(e), e) + + page = ok("get_device_parameters", track_index=t, device_index=0, page=0)["pages"][0]["parameters"] + check("unmodulated controls have no modulated_value", all("modulated_value" not in p for p in page), page) + + r = ok("show", track_index=t, device_index=0, panel_layout="EDIT") + check("show switches the layout and names the device", r["panel_layout"] == "EDIT" and r["device"] == "Polysynth", r) + r = ok("show", panel_layout="ARRANGE") + check("show back to ARRANGE", r["panel_layout"] == "ARRANGE", r) +finally: + names = [x["name"] for x in ok("list_tracks")] + while "b4 MIDI" in names: + ok("delete_track", track_index=names.index("b4 MIDI")); names = [x["name"] for x in ok("list_tracks")] + print("tracks after cleanup:", names) +print(f"\n{len(P)} passed, {len(F)} failed", F) +sys.exit(1 if F else 0) diff --git a/scripts/live_test_notes.py b/scripts/live_test_notes.py new file mode 100644 index 00000000..2d9c289f --- /dev/null +++ b/scripts/live_test_notes.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Live test for note expressions and conditions, clip settings, scenes, cue mix, crossfade and arranger clips. +Runs only against an EMPTY project; creates and deletes its own track and scenes. The test track is silenced +before anything plays. + +usage: python3 scripts/live_test_notes.py [--url http://localhost:61169/mcp] +""" +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_test import Mcp # noqa: E402 + +url = sys.argv[sys.argv.index("--url") + 1] if "--url" in sys.argv else "http://localhost:61169/mcp" +m = Mcp(url) +P, F = [], [] + + +def ok(tool_name, **a): + return m.tool(tool_name, **a) +def check(label, cond, detail=""): + (P if cond else F).append(label); print(("ok " if cond else "FAIL ") + label + ("" if cond else f" ({detail})"), flush=True) +def index_of(name): + return [x["name"] for x in ok("list_tracks")].index(name) +def scene_count(): + return len(ok("list_scenes")) + + +regular = [t for t in ok("list_tracks") if t["type"] not in ("master", "effect")] +if len(regular) > 2: + sys.exit(f"Refusing to run: the open project has {len(regular)} tracks. Open an empty project (File > New).") + +ok("create_track", type="instrument", name="b3 Notes") +scenes_before = scene_count() +cue_before = ok("project_state") +try: + t = index_of("b3 Notes") + ok("set_track", track_index=t, volume=0.0) + ok("insert_device", track_index=t, bitwig_device="Polysynth") + ok("create_clip", track_index=t, slot_index=0, length_beats=4) + ok("write_notes", track_index=t, slot_index=0, notes=[ + {"start": 0, "pitch": 60, "velocity": 100, "duration": 1}, {"start": 2, "pitch": 64, "velocity": 100, "duration": 1}]) + + r = ok("edit_notes", track_index=t, slot_index=0, pitch=60, release_velocity=0.8, velocity_spread=0.25, + occurrence="first", recurrence_length=4, recurrence_mask=5, repeats=3, repeat_curve=0.5, + repeat_velocity_end=-0.5) + check("edit_notes with expressions and conditions", r["notes_changed"] == 1, r) + n = [x for x in ok("read_notes", track_index=t, slot_index=0)["notes"] if x["pitch"] == 60][0] + print(" note:", n) + check("release_velocity and velocity_spread", abs(n.get("release_velocity", 0) - 0.8) < 0.01 + and abs(n.get("velocity_spread", 0) - 0.25) < 0.01, n) + check("occurrence", n.get("occurrence") == "first", n) + check("recurrence", n.get("recurrence_length") == 4 and n.get("recurrence_mask") == 5, n) + check("repeats with curve and velocity end", n.get("repeats") == 3 and abs(n.get("repeat_curve", 0) - 0.5) < 0.01 + and abs(n.get("repeat_velocity_end", 0) + 0.5) < 0.01, n) + ok("edit_notes", track_index=t, slot_index=0, pitch=60, occurrence="off", recurrence_length=0, repeats=0) + n = [x for x in ok("read_notes", track_index=t, slot_index=0)["notes"] if x["pitch"] == 60][0] + check("conditions switch off", not any(k in n for k in ("occurrence", "recurrence_length", "repeats")), n) + + r = ok("set_clip", track_index=t, slot_index=0, shuffle=True, accent=0.5, loop_enabled=False, + play_start_beats=1, color="#3080FF", name="b3 clip") + print(" clip:", r) + check("set_clip shuffle/loop/play start", r["shuffle"] is True and r["loop_enabled"] is False + and abs(r["play_start_beats"] - 1) < 1e-6, r) + check("set_clip accent", abs(r["accent"] - 0.5) < 0.01, r) + ok("set_clip", track_index=t, slot_index=0, loop_enabled=True, play_start_beats=0) + + while scene_count() < 2: + ok("create_scene"); time.sleep(0.3) + n0 = scene_count() + r = ok("duplicate_scene", scene_index=0) + check("duplicate_scene adds a scene with the clip", len(r["scenes"]) == n0 + 1 and r["scenes"][1]["clip_count"] == 1, r) + r = ok("move_scene", from_scene=1, to_scene=2) + check("move_scene moves the clips", r["scenes"][1]["clip_count"] == 0 and r["scenes"][2]["clip_count"] == 1, r) + r = ok("copy_scene", from_scene=2, to_scene=1) + check("copy_scene copies the clips", r["scenes"][1]["clip_count"] == 1 and r["scenes"][2]["clip_count"] == 1, r) + r = ok("set_scene", scene_index=1, name="b3 copy", color="#FF8000") + check("set_scene name", r["name"] == "b3 copy", r) + r = ok("delete_scene", scene_index=2) + check("delete_scene removes a scene", len(r["scenes"]) == n0, r) + + r1 = ok("set_cue", volume=0.5, mix=0.25) + r2 = ok("set_cue", volume=0.75, mix=0.5) + check("set_cue changes the cue bus", r1["cue_volume"] != r2["cue_volume"] and r1["cue_mix"] != r2["cue_mix"], (r1, r2)) + + r = ok("set_track", track_index=t, crossfade_mode="B") + check("set_track crossfade_mode", r["crossfade_mode"] == "B", r) + + try: + r = ok("read_notes", arranger=True) + check("read_notes arranger reads the selected arranger clip", "notes" in r and r.get("arranger") is True, r) + except RuntimeError as e: + check("read_notes arranger explains when nothing is selected", "arranger" in str(e), e) +finally: + ok("set_cue", volume=cue_before["cue_volume_raw"], mix=cue_before["cue_mix_raw"]) + names = [x["name"] for x in ok("list_tracks")] + while "b3 Notes" in names: + ok("delete_track", track_index=names.index("b3 Notes")); names = [x["name"] for x in ok("list_tracks")] + while scene_count() > max(scenes_before, 1): + ok("delete_scene", scene_index=scene_count() - 1) + print("tracks after cleanup:", names, "scenes:", scene_count()) +print(f"\n{len(P)} passed, {len(F)} failed", F) +sys.exit(1 if F else 0) diff --git a/scripts/live_test_project.py b/scripts/live_test_project.py new file mode 100644 index 00000000..24c14bf9 --- /dev/null +++ b/scripts/live_test_project.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Live test for the project tools (project state/reset, scenes, groove, transport options, clip copy/move/ +duplicate, note editing, clip transforms). Runs only against an EMPTY project; creates and deletes its own tracks. + +usage: python3 scripts/live_test_project.py [--url http://localhost:61169/mcp] +""" +import json +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from live_test import Mcp # noqa: E402 + +url = sys.argv[sys.argv.index("--url") + 1] if "--url" in sys.argv else "http://localhost:61169/mcp" +m = Mcp(url) +P, F = [], [] + + +def ok(tool_name, **a): + return m.tool(tool_name, **a) +def check(label, cond, detail=""): + (P if cond else F).append(label); print(("ok " if cond else "FAIL ") + label + ("" if cond else f" ({detail})"), flush=True) +regular = [t for t in ok("list_tracks") if t["type"] not in ("master", "effect")] +if len(regular) > 2: + sys.exit(f"Refusing to run: the open project has {len(regular)} tracks. Open an empty project (File > New).") +ok("set_engine_active", active=True) +a = ok("create_track", type="instrument", name="b1 A")["track_index"] +b = ok("create_track", type="instrument", name="b1 B")["track_index"] +try: + ok("set_track", track_index=a, solo=True); time.sleep(0.3) + check("project_state sees a solo", ok("project_state")["has_soloed_tracks"] is True) + r = ok("reset_project", unsolo=True) + check("reset_project unsolo", r["has_soloed_tracks"] is False, r) + n0 = len(ok("list_scenes")); ok("create_scene"); time.sleep(0.4) + check("create_scene adds a scene", len(ok("list_scenes")) == n0 + 1, (n0, len(ok("list_scenes")))) + g = ok("set_groove", shuffle_amount=0.75); print(" groove:", json.dumps(g)[:250]) + check("set_groove shuffle", any(abs(v["value"] - 0.75) < 0.01 for v in g.values()), g) + ok("set_groove", shuffle_amount=0.5) + t = ok("set_transport_options", metronome_volume=0.4, punch_in=True); print(" transport:", t) + check("set_transport_options punch_in", t["punch_in"] is True, t) + ok("set_transport_options", punch_in=False) + names = [x["name"] for x in ok("list_tracks")] + a, b = names.index("b1 A"), names.index("b1 B") + ok("insert_device", track_index=a, bitwig_device="Polysynth") + ok("create_clip", track_index=a, slot_index=0, length_beats=4) + ok("write_notes", track_index=a, slot_index=0, notes=[{"start": 0, "pitch": 60, "velocity": 100, "duration": 1}, {"start": 2, "pitch": 64, "velocity": 100, "duration": 1}]) + r = ok("copy_clip", from_track_index=a, from_slot=0, to_track_index=b, to_slot=0) + check("copy_clip to other track", r["target_has_clip"] and r["source_has_clip"], r) + r = ok("move_clip", from_track_index=b, from_slot=0, to_track_index=b, to_slot=2) + check("move_clip within track", r["target_has_clip"] and not r["source_has_clip"], r) + ok("duplicate_clip", track_index=a, slot_index=0); time.sleep(0.3) + slots = [c["slot_index"] for c in ok("get_track_details", track_index=a)["clips"] if c["has_content"]] + check("duplicate_clip fills next slot", 1 in slots, slots) + r = ok("edit_notes", track_index=a, slot_index=0, pitch=60, velocity=0.3, duration=0.5) + n60 = [n for n in ok("read_notes", track_index=a, slot_index=0)["notes"] if n["pitch"] == 60] + check("edit_notes velocity/duration on pitch 60", r["notes_changed"] == 1 and n60 and abs(n60[0]["velocity"] - 38) <= 1 and abs(n60[0]["duration"] - 0.5) < 1e-6, (r, n60)) + r = ok("transform_clip", track_index=a, slot_index=0, transpose=2); print(" transform:", r) + pitches = sorted(n["pitch"] for n in ok("read_notes", track_index=a, slot_index=0)["notes"]) + check("transform_clip transpose +2", pitches == [62, 66], pitches) +finally: + for name in ("b1 A", "b1 B"): + names = [x["name"] for x in ok("list_tracks")] + while name in names: + ok("delete_track", track_index=names.index(name)); names = [x["name"] for x in ok("list_tracks")] + print("tracks after cleanup:", [x["name"] for x in ok("list_tracks")]) +print(f"\n{len(P)} passed, {len(F)} failed", F) +sys.exit(1 if F else 0) diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigApiFacade.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigApiFacade.java index c9067162..4e05e354 100644 --- a/src/main/java/io/github/fabb/wigai/bitwig/BitwigApiFacade.java +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigApiFacade.java @@ -31,6 +31,7 @@ private static final class Constants { public static final String DEFAULT_TIME_STRING = "0:00.000"; public static final int MAX_TRACKS = 128; public static final int MAX_SCENES = 128; + public static final int MAX_SENDS = 8; public static final int MAX_DEVICES_PER_TRACK = 128; public static final int TICKS_PER_SIXTEENTH = 240; public static final int BEATS_PER_MEASURE = 4; @@ -90,7 +91,7 @@ public BitwigApiFacade(ControllerHost host, Logger logger) { this.projectParameterBank = masterTrack.createCursorRemoteControlsPage(Constants.PROJECT_PARAMETER_COUNT); // Initialize track bank for clip launching (support up to 128 tracks and 128 scenes for full functionality) - this.trackBank = host.createTrackBank(Constants.MAX_TRACKS, 0, Constants.MAX_SCENES); + this.trackBank = host.createTrackBank(Constants.MAX_TRACKS, Constants.MAX_SENDS, Constants.MAX_SCENES); this.sceneBankFacade = new SceneBankFacade(host, logger, Constants.MAX_SCENES); // Support up to 128 scenes for full functionality // Initialize device banks for each track to enable device enumeration @@ -263,6 +264,26 @@ private int getTrackIndexByName(String trackName) { return -1; } + // ======================================== + // Accessors for BitwigEditFacade + // ======================================== + + public Application getApplication() { + return application; + } + + public Transport getTransport() { + return transport; + } + + public TrackBank getTrackBank() { + return trackBank; + } + + public List getTrackDeviceBanks() { + return trackDeviceBanks; + } + // ======================================== // Public API Methods // ======================================== diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigContainerFacade.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigContainerFacade.java new file mode 100644 index 00000000..1746e7a7 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigContainerFacade.java @@ -0,0 +1,647 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Inside container devices and Bitwig's library: Drum Machine pads, layer mixers (Instrument/FX Layer), + * presets on disk, and the popup browser. Bitwig objects are created in the constructor (during init()). + */ +public class BitwigContainerFacade { + + private static final class Constants { + static final int PADS = 128; + static final int PAD_DEVICES = 8; + static final int LAYERS = 16; + static final int BROWSER_ITEMS = 64; + static final int PRESET_HEADER_BYTES = 4096; + private Constants() {} + } + + private static final String[] NOTE_NAMES = {"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"}; + + private final Logger logger; + private final BitwigEditFacade edit; + private final PinnableCursorDevice device; + private final DrumPadBank pads; + private final List padDevices = new ArrayList<>(); + private final DeviceLayerBank layers; + private final List layerDevices = new ArrayList<>(); + private final PopupBrowser browser; + private final Map columns = new LinkedHashMap<>(); + private final Map columnItems = new LinkedHashMap<>(); + private final Map columnCursors = new LinkedHashMap<>(); + private final BrowserResultsItemBank results; + private final List presetRoots; + private List> presetIndex; + + public BitwigContainerFacade(ControllerHost host, Logger logger, BitwigEditFacade edit, List presetRoots) { + this.logger = logger; + this.edit = edit; + this.presetRoots = presetRoots; + this.device = edit.editTrack().createCursorDevice("WIGAI_CONTAINER_DEVICE", "WigAI container device", 0, + CursorDeviceFollowMode.FIRST_DEVICE); + device.exists().markInterested(); + device.name().markInterested(); + device.hasDrumPads().markInterested(); + device.hasLayers().markInterested(); + device.isNested().markInterested(); + device.position().markInterested(); + device.presetName().markInterested(); + + this.pads = device.createDrumPadBank(Constants.PADS); + pads.scrollPosition().markInterested(); + pads.scrollPosition().set(0); + pads.hasMutedPads().markInterested(); + pads.hasSoloedPads().markInterested(); + for (int i = 0; i < pads.getSizeOfBank(); i++) { + padDevices.add(markChannel(pads.getItemAt(i))); + } + this.layers = device.createLayerBank(Constants.LAYERS); + for (int i = 0; i < layers.getSizeOfBank(); i++) { + layerDevices.add(markChannel(layers.getItemAt(i))); + } + + this.browser = host.createPopupBrowser(); + browser.exists().markInterested(); + browser.title().markInterested(); + browser.contentTypeNames().markInterested(); + browser.selectedContentTypeName().markInterested(); + browser.selectedContentTypeIndex().markInterested(); + columns.put("collection", browser.smartCollectionColumn()); + columns.put("location", browser.locationColumn()); + columns.put("device", browser.deviceColumn()); + columns.put("category", browser.categoryColumn()); + columns.put("tag", browser.tagColumn()); + columns.put("device_type", browser.deviceTypeColumn()); + columns.put("file_type", browser.fileTypeColumn()); + columns.put("creator", browser.creatorColumn()); + for (Map.Entry e : columns.entrySet()) { + BrowserFilterColumn column = e.getValue(); + column.exists().markInterested(); + column.entryCount().markInterested(); + BrowserFilterItemBank bank = column.createItemBank(Constants.BROWSER_ITEMS); + bank.scrollPosition().markInterested(); + markItems(bank); + columnItems.put(e.getKey(), bank); + BrowserFilterItem cursor = column.createCursorItem(); + cursor.exists().markInterested(); + cursor.name().markInterested(); + columnCursors.put(e.getKey(), cursor); + } + BrowserResultsColumn resultsColumn = browser.resultsColumn(); + resultsColumn.entryCount().markInterested(); + this.results = resultsColumn.createItemBank(Constants.BROWSER_ITEMS); + results.scrollPosition().markInterested(); + markItems(results); + } + + private static DeviceBank markChannel(Channel c) { + c.exists().markInterested(); + c.name().markInterested(); + c.isActivated().markInterested(); + c.mute().markInterested(); + c.solo().markInterested(); + c.isMutedBySolo().markInterested(); + c.volume().value().markInterested(); + c.volume().displayedValue().markInterested(); + c.pan().value().markInterested(); + c.pan().displayedValue().markInterested(); + DeviceBank bank = c.createDeviceBank(Constants.PAD_DEVICES); + for (int d = 0; d < bank.getSizeOfBank(); d++) { + bank.getItemAt(d).exists().markInterested(); + bank.getItemAt(d).name().markInterested(); + bank.getItemAt(d).presetName().markInterested(); + } + return bank; + } + + private static void markItems(BrowserItemBank bank) { + for (int i = 0; i < bank.getSizeOfBank(); i++) { + BrowserItem item = bank.getItemAt(i); + item.exists().markInterested(); + item.name().markInterested(); + item.isSelected().markInterested(); + } + } + + private static String noteName(int key) { + return NOTE_NAMES[key % 12] + (key / 12 - 2); + } + + private static List> deviceList(DeviceBank bank) { + List> out = new ArrayList<>(); + for (int d = 0; d < bank.getSizeOfBank(); d++) { + Device dev = bank.getItemAt(d); + if (dev.exists().get()) { + Map m = new LinkedHashMap<>(); + m.put("index", d); + m.put("name", dev.name().get()); + String preset = dev.presetName().get(); + if (preset != null && !preset.isEmpty()) { + m.put("preset", preset); + } + out.add(m); + } + } + return out; + } + + private static Map channelState(Channel c, DeviceBank devices) { + Map m = new LinkedHashMap<>(); + m.put("name", c.name().get()); + m.put("volume", c.volume().displayedValue().get()); + m.put("pan", c.pan().displayedValue().get()); + m.put("mute", c.mute().get()); + m.put("solo", c.solo().get()); + if (c.isMutedBySolo().get()) { + m.put("muted_by_solo", true); + } + if (!c.isActivated().get()) { + m.put("activated", false); + } + m.put("devices", deviceList(devices)); + return m; + } + + private void applyMix(Channel c, String name, Double volume, Double volumeDb, Double pan, Boolean mute, Boolean solo, + Boolean activated) { + if (name != null) { + c.name().set(name); + } + if (volume != null) { + c.volume().value().setImmediately(volume); + } + if (volumeDb != null) { + edit.setVolumeDb(c.volume(), volumeDb); + } + if (pan != null) { + c.pan().value().setImmediately((pan + 1.0) / 2.0); + } + if (mute != null) { + c.mute().set(mute); + } + if (solo != null) { + c.solo().set(solo); + } + if (activated != null) { + c.isActivated().set(activated); + } + edit.settle(); + } + + // ======================================== + // Drum Machine pads + // ======================================== + + private void focusDrumMachine(int trackIndex, int deviceIndex, String operation) throws BitwigApiException { + edit.focusDevice(device, trackIndex, deviceIndex, operation); + if (!edit.waitFor(() -> device.hasDrumPads().get(), 500)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "Device '" + device.name().get() + "' has no drum pads (use a Drum Machine)"); + } + edit.waitFor(() -> pads.scrollPosition().get() == 0, 500); + } + + private DrumPad pad(int key, String operation) throws BitwigApiException { + if (key < 0 || key >= pads.getSizeOfBank()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "pad must be a MIDI key 0-127"); + } + return pads.getItemAt(key); + } + + private Map padState(int key) { + Map m = new LinkedHashMap<>(); + m.put("pad", key); + m.put("note", noteName(key)); + m.putAll(channelState(pads.getItemAt(key), padDevices.get(key))); + return m; + } + + /** The pads of a Drum Machine, keyed by MIDI note. Empty pads are left out unless includeEmpty. */ + public Map listDrumPads(int trackIndex, int deviceIndex, boolean includeEmpty) throws BitwigApiException { + focusDrumMachine(trackIndex, deviceIndex, "listDrumPads"); + List> list = new ArrayList<>(); + for (int k = 0; k < pads.getSizeOfBank(); k++) { + DrumPad p = pads.getItemAt(k); + boolean filled = p.exists().get() && padDevices.get(k).getItemAt(0).exists().get(); + if (filled || includeEmpty) { + list.add(padState(k)); + } + } + Map m = new LinkedHashMap<>(); + m.put("device", device.name().get()); + m.put("has_muted_pads", pads.hasMutedPads().get()); + m.put("has_soloed_pads", pads.hasSoloedPads().get()); + m.put("pads", list); + return m; + } + + public Map setDrumPad(int trackIndex, int deviceIndex, int key, String name, Double volume, + Double volumeDb, Double pan, Boolean mute, Boolean solo, Boolean activated) + throws BitwigApiException { + focusDrumMachine(trackIndex, deviceIndex, "setDrumPad"); + applyMix(pad(key, "setDrumPad"), name, volume, volumeDb, pan, mute, solo, activated); + return padState(key); + } + + /** Puts a device, plugin, preset or sample (Bitwig makes a Sampler for it) on a pad, after what is there. */ + public Map insertIntoDrumPad(int trackIndex, int deviceIndex, int key, String bitwigDevice, + String clapId, String vst3Id, String file) throws BitwigApiException { + final String operation = "insertIntoDrumPad"; + focusDrumMachine(trackIndex, deviceIndex, operation); + DrumPad p = pad(key, operation); + DeviceBank bank = padDevices.get(key); + int before = deviceList(bank).size(); + edit.insertInto(p.insertionPoint(), bitwigDevice, clapId, vst3Id, file, operation); + boolean added = edit.waitFor(() -> deviceList(bank).size() > before, 4000); + edit.settle(); + Map m = padState(key); + m.put("inserted", added); + return m; + } + + public Map clearDrumPads(int trackIndex, int deviceIndex, boolean mutes, boolean solos) + throws BitwigApiException { + focusDrumMachine(trackIndex, deviceIndex, "clearDrumPads"); + if (mutes) { + pads.clearMutedPads(); + } + if (solos) { + pads.clearSoloedPads(); + } + edit.settle(); + return Map.of("has_muted_pads", pads.hasMutedPads().get(), "has_soloed_pads", pads.hasSoloedPads().get()); + } + + // ======================================== + // Layer mixers (Instrument Layer, FX Layer, ...) + // ======================================== + + private void focusLayered(int trackIndex, int deviceIndex, String operation) throws BitwigApiException { + edit.focusDevice(device, trackIndex, deviceIndex, operation); + if (!edit.waitFor(() -> device.hasLayers().get(), 500)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "Device '" + device.name().get() + "' has no layers (use Instrument Layer, FX Layer, ...)"); + } + } + + private Map layerState(int i) { + Map m = new LinkedHashMap<>(); + m.put("layer", i); + m.putAll(channelState(layers.getItemAt(i), layerDevices.get(i))); + return m; + } + + public Map listLayers(int trackIndex, int deviceIndex) throws BitwigApiException { + focusLayered(trackIndex, deviceIndex, "listLayers"); + List> list = new ArrayList<>(); + for (int i = 0; i < layers.getSizeOfBank(); i++) { + if (layers.getItemAt(i).exists().get()) { + list.add(layerState(i)); + } + } + return Map.of("device", device.name().get(), "layers", list); + } + + public Map setLayer(int trackIndex, int deviceIndex, int layer, String name, Double volume, + Double volumeDb, Double pan, Boolean mute, Boolean solo, Boolean activated) + throws BitwigApiException { + focusLayered(trackIndex, deviceIndex, "setLayer"); + applyMix(layer(layer, "setLayer"), name, volume, volumeDb, pan, mute, solo, activated); + return layerState(layer); + } + + private DeviceLayer layer(int i, String operation) throws BitwigApiException { + if (i < 0 || i >= layers.getSizeOfBank() || !layers.getItemAt(i).exists().get()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "No layer " + i + " in this device"); + } + return layers.getItemAt(i); + } + + // ======================================== + // Presets on disk + // ======================================== + + /** Default places Bitwig keeps presets: installed packages and the user library. */ + public static List defaultPresetRoots() { + Path home = Path.of(System.getProperty("user.home")); + return List.of(home.resolve(".BitwigStudio").resolve("installed-packages"), home.resolve("Bitwig Studio")); + } + + private synchronized List> presetIndex() { + if (presetIndex == null) { + presetIndex = scanPresets(presetRoots, logger); + } + return presetIndex; + } + + /** Every .bwpreset under the roots, with the device and category when the file is not encrypted. */ + static List> scanPresets(List roots, Logger logger) { + List> index = new ArrayList<>(); + for (Path root : roots) { + if (!Files.isDirectory(root)) { + continue; + } + try (Stream files = Files.walk(root)) { + files.filter(p -> p.getFileName().toString().endsWith(".bwpreset")).sorted().forEach(p -> { + Map m = new LinkedHashMap<>(); + String file = p.getFileName().toString(); + m.put("name", file.substring(0, file.length() - ".bwpreset".length())); + m.put("collection", root.relativize(p.getParent()).toString()); + byte[] head = readHead(p); + String dev = BitwigEditFacade.presetMeta(head, "device_name"); + if (dev != null) { + m.put("device", dev); + } + String category = BitwigEditFacade.presetMeta(head, "preset_category"); + if (category != null && !category.isEmpty()) { + m.put("category", category); + } + m.put("path", p.toString()); + index.add(m); + }); + } catch (IOException e) { + logger.warn("Could not scan presets under " + root + ": " + e.getMessage()); + } + } + return index; + } + + private static byte[] readHead(Path p) { + try (InputStream in = Files.newInputStream(p)) { + return in.readNBytes(Constants.PRESET_HEADER_BYTES); + } catch (IOException e) { + return new byte[0]; + } + } + + /** + * Presets found on disk. Every word of the query must appear in the name, collection, device or category. + * Packaged presets are encrypted, so their device is only known from the browser (browse_presets). + */ + public Map listPresets(String query, Integer limit, boolean rescan) { + if (rescan) { + synchronized (this) { + presetIndex = null; + } + } + return searchPresets(presetIndex(), query, limit == null ? 100 : limit); + } + + static Map searchPresets(List> all, String query, int max) { + String[] words = query == null ? new String[0] : query.toLowerCase(Locale.ROOT).trim().split("\\s+"); + List> hits = new ArrayList<>(); + int total = 0; + for (Map m : all) { + String hay = (m.get("name") + " " + m.get("collection") + " " + m.getOrDefault("device", "") + " " + + m.getOrDefault("category", "")).toLowerCase(Locale.ROOT); + boolean match = true; + for (String w : words) { + if (!w.isEmpty() && !hay.contains(w)) { + match = false; + break; + } + } + if (match) { + total++; + if (hits.size() < max) { + hits.add(m); + } + } + } + Map out = new LinkedHashMap<>(); + out.put("indexed", all.size()); + out.put("matches", total); + out.put("presets", hits); + return out; + } + + // ======================================== + // Popup browser + // ======================================== + + /** + * Opens Bitwig's browser for an insertion point. mode: replace (the device; Bitwig filters to its presets), + * after/before (the device), start/end (the track's chain). + */ + public Map openBrowser(int trackIndex, Integer deviceIndex, String mode, String contentType) + throws BitwigApiException { + final String operation = "openBrowser"; + if (browser.exists().get()) { + browser.cancel(); + edit.waitFor(() -> !browser.exists().get()); + } + String m = mode == null ? (deviceIndex == null ? "end" : "replace") : mode.toLowerCase(Locale.ROOT); + InsertionPoint ip; + if (m.equals("start") || m.equals("end")) { + Track track = edit.trackAt(trackIndex); + ip = m.equals("start") ? track.startOfDeviceChainInsertionPoint() : track.endOfDeviceChainInsertionPoint(); + } else { + if (deviceIndex == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "device_index is required for mode " + m); + } + edit.focusDevice(device, trackIndex, deviceIndex, operation); + ip = switch (m) { + case "replace" -> device.replaceDeviceInsertionPoint(); + case "after" -> device.afterDeviceInsertionPoint(); + case "before" -> device.beforeDeviceInsertionPoint(); + default -> throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "mode must be replace, after, before, start or end"); + }; + } + ip.browse(); + if (!edit.waitFor(() -> browser.exists().get(), 4000)) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, "The browser did not open"); + } + edit.settle(); + if (contentType != null) { + selectContentType(contentType, operation); + } + return browserState(null, 0); + } + + private void requireOpen(String operation) throws BitwigApiException { + if (!browser.exists().get()) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "The browser is not open (browser_open)"); + } + } + + private void selectContentType(String name, String operation) throws BitwigApiException { + String[] names = browser.contentTypeNames().get(); + for (int i = 0; i < names.length; i++) { + if (names[i].equalsIgnoreCase(name.trim())) { + int idx = i; + browser.selectedContentTypeIndex().set(idx); + edit.waitFor(() -> browser.selectedContentTypeIndex().get() == idx); + edit.settle(); + return; + } + } + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "No content type '" + name + "'. Types: " + String.join(", ", names)); + } + + private static List names(BrowserItemBank bank) { + List out = new ArrayList<>(); + for (int i = 0; i < bank.getSizeOfBank(); i++) { + BrowserItem item = bank.getItemAt(i); + if (item.exists().get()) { + out.add(item.name().get()); + } + } + return out; + } + + private static void scrollTo(BitwigEditFacade edit, BrowserItemBank bank, int position) { + if (bank.scrollPosition().get() != position) { + bank.scrollPosition().set(position); + edit.waitFor(() -> bank.scrollPosition().get() == position); + edit.settle(); + } + } + + /** + * The browser's content type, the selected item of each filter column, and one page of results. + * With listColumn, also every item of that column (to pick a filter from). + */ + public Map browserState(String listColumn, int resultsPage) throws BitwigApiException { + requireOpen("browserState"); + Map m = new LinkedHashMap<>(); + m.put("title", browser.title().get()); + m.put("content_types", List.of(browser.contentTypeNames().get())); + String[] types = browser.contentTypeNames().get(); + int typeIndex = browser.selectedContentTypeIndex().get(); + m.put("content_type", typeIndex >= 0 && typeIndex < types.length ? types[typeIndex] : browser.selectedContentTypeName().get()); + Map filters = new LinkedHashMap<>(); + for (Map.Entry e : columns.entrySet()) { + if (!e.getValue().exists().get()) { + continue; + } + BrowserFilterItem cursor = columnCursors.get(e.getKey()); + filters.put(e.getKey(), Map.of("selected", cursor.exists().get() ? cursor.name().get() : "", + "items", e.getValue().entryCount().get())); + } + m.put("filters", filters); + if (listColumn != null) { + m.put(listColumn + "_items", columnItemNames(listColumn, "browserState")); + } + scrollTo(edit, results, Math.max(0, resultsPage) * Constants.BROWSER_ITEMS); + m.put("result_count", browser.resultsColumn().entryCount().get()); + m.put("results_page", resultsPage); + m.put("results", names(results)); + return m; + } + + private BrowserFilterColumn column(String name, String operation) throws BitwigApiException { + BrowserFilterColumn c = columns.get(name == null ? "" : name.toLowerCase(Locale.ROOT)); + if (c == null) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "column must be one of " + String.join(", ", columns.keySet())); + } + return c; + } + + private List columnItemNames(String name, String operation) throws BitwigApiException { + BrowserFilterColumn c = column(name, operation); + BrowserFilterItemBank bank = columnItems.get(name.toLowerCase(Locale.ROOT)); + List out = new ArrayList<>(); + int count = c.entryCount().get(); + for (int pos = 0; pos < Math.max(count, 1); pos += Constants.BROWSER_ITEMS) { + scrollTo(edit, bank, pos); + out.addAll(names(bank)); + } + scrollTo(edit, bank, 0); + return out; + } + + /** Finds an item by exact (case-insensitive) name, paging through the bank, and selects it. */ + private boolean selectByName(BrowserItemBank bank, int count, String wanted) { + for (int pos = 0; pos < Math.max(count, 1); pos += Constants.BROWSER_ITEMS) { + scrollTo(edit, bank, pos); + for (int i = 0; i < bank.getSizeOfBank(); i++) { + BrowserItem item = bank.getItemAt(i); + if (item.exists().get() && item.name().get().equalsIgnoreCase(wanted.trim())) { + item.isSelected().set(true); + edit.waitFor(() -> item.isSelected().get()); + edit.settle(); + return true; + } + } + } + return false; + } + + /** Sets filters by column name and item name (e.g. {"category": "Bass"}); "Any ..." items clear a column. */ + public Map filterBrowser(String contentType, Map filters) throws BitwigApiException { + final String operation = "filterBrowser"; + requireOpen(operation); + if (contentType != null) { + selectContentType(contentType, operation); + } + for (Map.Entry e : filters.entrySet()) { + BrowserFilterColumn c = column(e.getKey(), operation); + String key = e.getKey().toLowerCase(Locale.ROOT); + if (!selectByName(columnItems.get(key), c.entryCount().get(), e.getValue())) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "No '" + e.getValue() + "' in the " + key + " column. Items: " + + String.join(", ", columnItemNames(key, operation))); + } + } + return browserState(null, 0); + } + + /** Selects a result by name (or index into all results) and loads it; cancel closes without loading. */ + public Map commitBrowser(String result, Integer resultIndex) throws BitwigApiException { + final String operation = "commitBrowser"; + requireOpen(operation); + String loaded = null; + if (result != null) { + if (!selectByName(results, browser.resultsColumn().entryCount().get(), result)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "No result named '" + result + "'"); + } + loaded = result; + } else if (resultIndex != null) { + int page = resultIndex / Constants.BROWSER_ITEMS * Constants.BROWSER_ITEMS; + scrollTo(edit, results, page); + BrowserResultsItem item = results.getItemAt(resultIndex - page); + if (!item.exists().get()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "No result at index " + resultIndex); + } + loaded = item.name().get(); + item.isSelected().set(true); + edit.waitFor(() -> item.isSelected().get()); + edit.settle(); + } + browser.commit(); + edit.waitFor(() -> !browser.exists().get(), 4000); + edit.settle(); + Map m = new LinkedHashMap<>(); + m.put("committed", true); + m.put("loaded", loaded == null ? "(the selected result)" : loaded); + m.put("browser_open", browser.exists().get()); + return m; + } + + public Map cancelBrowser() { + if (browser.exists().get()) { + browser.cancel(); + edit.waitFor(() -> !browser.exists().get()); + } + return Map.of("browser_open", browser.exists().get()); + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java new file mode 100644 index 00000000..91a2b4fe --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigEditFacade.java @@ -0,0 +1,1545 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.function.BooleanSupplier; + +/** + * Facade for operations that change the project: creating tracks, inserting and editing devices, + * mixer settings, clips, notes and application actions. + * + * All Bitwig objects are created in the constructor (Bitwig only allows that during init()). + * Bitwig applies changes asynchronously, so mutating methods poll for the result before returning. + * Device parameters are reached through a private cursor track and cursor device that do not + * follow the user's selection, so reading or editing a device never moves the user's focus. + */ +public class BitwigEditFacade { + + private static final class Constants { + public static final int REMOTE_CONTROL_COUNT = 8; + public static final int CLIP_GRID_WIDTH = 16384; + public static final int CLIP_GRID_HEIGHT = 128; + public static final double CLIP_STEP_BEATS = 1.0 / 16.0; // 64th-note resolution + public static final long POLL_INTERVAL_MS = 20; + public static final long DEFAULT_TIMEOUT_MS = 2000; + public static final long SETTLE_MS = 120; + public static final String DEVICE_LIST_RESOURCE = "/bitwig-devices.txt"; + public static final int CHAIN_DEVICES = 32; + public static final int LAYERS = 16; + + private Constants() {} + } + + private final ControllerHost host; + private final Application application; + private final Transport transport; + private final Logger logger; + private final TrackBank trackBank; + private final List trackDeviceBanks; + private final CursorTrack editTrack; + private final PinnableCursorDevice editDevice; + private final ChainCursor primary; + private final ChainCursor secondary; + private final CursorRemoteControlsPage editPage; + private final Clip editClip; + private final Map bitwigDevices; + private final long timeoutMs; + + public BitwigEditFacade(ControllerHost host, Logger logger, Application application, Transport transport, + TrackBank trackBank, List trackDeviceBanks) { + this(host, logger, application, transport, trackBank, trackDeviceBanks, Constants.DEFAULT_TIMEOUT_MS); + } + + BitwigEditFacade(ControllerHost host, Logger logger, Application application, Transport transport, + TrackBank trackBank, List trackDeviceBanks, long timeoutMs) { + this.host = host; + this.logger = logger; + this.application = application; + this.transport = transport; + this.trackBank = trackBank; + this.trackDeviceBanks = trackDeviceBanks; + this.timeoutMs = timeoutMs; + this.bitwigDevices = loadBitwigDevices(); + + this.editTrack = host.createCursorTrack("WIGAI_EDIT_TRACK", "WigAI edit track", 0, 0, false); + this.editDevice = editTrack.createCursorDevice("WIGAI_EDIT_DEVICE", "WigAI edit device", 0, + CursorDeviceFollowMode.FIRST_DEVICE); + this.editPage = editDevice.createCursorRemoteControlsPage(Constants.REMOTE_CONTROL_COUNT); + this.editClip = editTrack.createLauncherCursorClip(Constants.CLIP_GRID_WIDTH, Constants.CLIP_GRID_HEIGHT); + this.primary = new ChainCursor(editDevice); + this.secondary = new ChainCursor(editTrack.createCursorDevice("WIGAI_EDIT_DEVICE_2", "WigAI edit device 2", 0, + CursorDeviceFollowMode.FIRST_DEVICE)); + + application.panelLayout().markInterested(); + editTrack.position().markInterested(); + editTrack.name().markInterested(); + editDevice.exists().markInterested(); + editDevice.name().markInterested(); + editDevice.position().markInterested(); + editPage.pageNames().markInterested(); + editPage.selectedPageIndex().markInterested(); + for (int i = 0; i < editPage.getParameterCount(); i++) { + RemoteControl p = editPage.getParameter(i); + p.exists().markInterested(); + p.name().markInterested(); + p.value().markInterested(); + p.displayedValue().markInterested(); + p.modulatedValue().markInterested(); + p.modulatedValue().displayedValue().markInterested(); + } + editClip.exists().markInterested(); + editClip.getLoopLength().markInterested(); + editClip.setStepSize(Constants.CLIP_STEP_BEATS); + editClip.scrollToKey(0); + editClip.scrollToStep(0); + + for (int t = 0; t < trackBank.getSizeOfBank(); t++) { + Track track = trackBank.getItemAt(t); + track.position().markInterested(); + track.trackType().markInterested(); + track.color().markInterested(); + track.isActivated().markInterested(); + track.solo().markInterested(); + track.arm().markInterested(); + track.mute().markInterested(); + track.volume().value().markInterested(); + track.volume().displayedValue().markInterested(); + track.pan().value().markInterested(); + track.pan().displayedValue().markInterested(); + track.monitorMode().markInterested(); + track.isGroup().markInterested(); + track.isGroupExpanded().markInterested(); + track.crossFadeMode().markInterested(); + SendBank sends = sendBankOrNull(track); + for (int i = 0; sends != null && i < sends.getSizeOfBank(); i++) { + Send send = sends.getItemAt(i); + send.exists().markInterested(); + send.name().markInterested(); + send.value().markInterested(); + send.displayedValue().markInterested(); + send.isEnabled().markInterested(); + } + ClipLauncherSlotBank slots = track.clipLauncherSlotBank(); + for (int s = 0; s < slots.getSizeOfBank(); s++) { + slots.getItemAt(s).hasContent().markInterested(); + } + DeviceBank bank = trackDeviceBanks.get(t); + for (int d = 0; d < bank.getSizeOfBank(); d++) { + bank.getItemAt(d).position().markInterested(); + bank.getItemAt(d).presetName().markInterested(); + } + } + transport.tempo().value().markInterested(); + transport.tempo().displayedValue().markInterested(); + transport.getPosition().markInterested(); + transport.isArrangerLoopEnabled().markInterested(); + transport.isMetronomeEnabled().markInterested(); + transport.isArrangerRecordEnabled().markInterested(); + transport.isArrangerOverdubEnabled().markInterested(); + transport.isAutomationOverrideActive().markInterested(); + transport.timeSignature().numerator().markInterested(); + transport.timeSignature().denominator().markInterested(); + } + + // ======================================== + // Lookup helpers + // ======================================== + + /** Resolves a track by index or exact name to its index in the flat track bank. */ + public int resolveTrackIndex(Integer trackIndex, String trackName, String operation) throws BitwigApiException { + if (trackIndex != null) { + if (trackIndex < 0 || trackIndex >= trackBank.getSizeOfBank() || !trackBank.getItemAt(trackIndex).exists().get()) { + throw new BitwigApiException(ErrorCode.TRACK_NOT_FOUND, operation, "No track at index " + trackIndex); + } + return trackIndex; + } + if (trackName != null) { + for (int i = 0; i < trackBank.getSizeOfBank(); i++) { + Track t = trackBank.getItemAt(i); + if (t.exists().get() && trackName.equals(t.name().get())) { + return i; + } + } + throw new BitwigApiException(ErrorCode.TRACK_NOT_FOUND, operation, "No track named '" + trackName + "'"); + } + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, "Provide track_index or track_name"); + } + + private Device resolveDevice(int trackIndex, int deviceIndex, String operation) throws BitwigApiException { + DeviceBank bank = trackDeviceBanks.get(trackIndex); + if (deviceIndex < 0 || deviceIndex >= bank.getSizeOfBank() || !bank.getItemAt(deviceIndex).exists().get()) { + throw new BitwigApiException(ErrorCode.DEVICE_NOT_FOUND, operation, + "No device at index " + deviceIndex + " on track " + trackIndex); + } + return bank.getItemAt(deviceIndex); + } + + /** + * Number of regular tracks. The flat track list holds regular tracks first, then effect tracks, + * then the master track; only regular tracks can be inserted between. + */ + public int countTracks() { + return countTracksOfKind(false); + } + + public int countEffectTracks() { + return countTracksOfKind(true); + } + + private int countTracksOfKind(boolean effect) { + int n = 0; + for (int i = 0; i < trackBank.getSizeOfBank(); i++) { + Track t = trackBank.getItemAt(i); + if (t.exists().get() && !isMaster(t) && isEffect(t) == effect) { + n++; + } + } + return n; + } + + static boolean isMaster(Track track) { + return "Master".equalsIgnoreCase(track.trackType().get()); + } + + static boolean isEffect(Track track) { + return "Effect".equalsIgnoreCase(track.trackType().get()); + } + + public int countDevices(int trackIndex) { + DeviceBank bank = trackDeviceBanks.get(trackIndex); + int n = 0; + for (int i = 0; i < bank.getSizeOfBank(); i++) { + if (bank.getItemAt(i).exists().get()) { + n = i + 1; + } + } + return n; + } + + private List> deviceSummaries(int trackIndex) { + List> out = new ArrayList<>(); + DeviceBank bank = trackDeviceBanks.get(trackIndex); + for (int i = 0; i < bank.getSizeOfBank(); i++) { + Device d = bank.getItemAt(i); + if (!d.exists().get()) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("index", i); + m.put("name", d.name().get()); + m.put("enabled", d.isEnabled().get()); + putPreset(m, d); + out.add(m); + } + return out; + } + + private static void putPreset(Map m, Device d) { + String preset = d.presetName().get(); + if (preset != null && !preset.isEmpty() && !"init".equals(preset)) { + m.put("preset", preset); + } + } + + boolean waitFor(BooleanSupplier condition) { + return waitFor(condition, timeoutMs); + } + + boolean waitFor(BooleanSupplier condition, long limitMs) { + long deadline = System.currentTimeMillis() + Math.min(limitMs, timeoutMs); + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + sleep(Constants.POLL_INTERVAL_MS); + } + return condition.getAsBoolean(); + } + + void settle() { + if (timeoutMs > 0) { + sleep(Constants.SETTLE_MS); + } + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ======================================== + // Tracks + // ======================================== + + public Map createTrack(String type, Integer position, String name) throws BitwigApiException { + final String operation = "createTrack"; + boolean effect = "effect".equals(type); + int regular = countTracks(); + int effects = countEffectTracks(); + int before = effect ? effects : regular; + int pos = position == null ? before : position; + if (pos < 0 || pos > before) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, + "position must be 0.." + before + (effect ? " (among effect tracks)" : "")); + } + switch (type) { + case "audio" -> application.createAudioTrack(pos); + case "instrument" -> application.createInstrumentTrack(pos); + case "effect" -> application.createEffectTrack(pos); + default -> throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "type must be audio, instrument or effect"); + } + if (!waitFor(() -> (effect ? countEffectTracks() : countTracks()) > before)) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, "Track was not created in time"); + } + int flatIndex = effect ? countTracks() + pos : pos; + Track created = trackBank.getItemAt(flatIndex); + String expectedType = effect ? "Effect" : type; + if (!created.exists().get() || isMaster(created) || !expectedType.equalsIgnoreCase(created.trackType().get())) { + throw new BitwigApiException(ErrorCode.OPERATION_FAILED, operation, + "Track was created but could not be located at index " + flatIndex + "; not renaming anything"); + } + if (name != null) { + created.name().set(name); + waitFor(() -> name.equals(created.name().get())); + } + Map result = new LinkedHashMap<>(); + result.put("type", type); + result.put("track_index", flatIndex); + result.put("name", created.name().get()); + return result; + } + + public Map deleteTrack(int trackIndex) throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + if (isMaster(track)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, "deleteTrack", "Refusing to delete the master track"); + } + String name = track.name().get(); + int before = countTracks(); + track.deleteObject(); + waitFor(() -> countTracks() < before); + return Map.of("deleted_track", name, "track_index", trackIndex); + } + + public Map duplicateTrack(int trackIndex) throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + if (isMaster(track)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, "duplicateTrack", "The master track cannot be duplicated"); + } + int before = countTracks(); + track.duplicate(); + waitFor(() -> countTracks() > before); + return Map.of("duplicated_track", track.name().get(), "new_track_index", trackIndex + 1); + } + + /** + * Applies any non-null mixer/track setting. volumeDb is matched against Bitwig's displayed dB value, + * so it is exact regardless of the fader curve. + */ + public Map setTrackProperties(int trackIndex, String name, Double volume, Double volumeDb, Double pan, + Boolean mute, Boolean solo, Boolean arm, Boolean activated, + String monitorMode, String color) throws BitwigApiException { + return setTrackProperties(trackIndex, name, volume, volumeDb, pan, mute, solo, arm, activated, monitorMode, color, + null, null); + } + + public Map setTrackProperties(int trackIndex, String name, Double volume, Double volumeDb, Double pan, + Boolean mute, Boolean solo, Boolean arm, Boolean activated, + String monitorMode, String color, Boolean groupExpanded, + String crossfadeMode) throws BitwigApiException { + final String operation = "setTrackProperties"; + Track track = trackBank.getItemAt(trackIndex); + if (groupExpanded != null) { + if (!track.isGroup().get()) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "Track is not a group"); + } + track.isGroupExpanded().set(groupExpanded); + } + if (crossfadeMode != null) { + track.crossFadeMode().set(crossfadeMode.toUpperCase(Locale.ROOT)); + } + if (name != null) { + track.name().set(name); + } + if (volume != null) { + track.volume().value().setImmediately(volume); + } + if (volumeDb != null) { + setVolumeDb(track.volume(), volumeDb); + } + if (pan != null) { + track.pan().value().setImmediately((pan + 1.0) / 2.0); + } + if (mute != null) { + track.mute().set(mute); + } + if (solo != null) { + track.solo().set(solo); + } + if (arm != null) { + track.arm().set(arm); + } + if (activated != null) { + track.isActivated().set(activated); + } + if (monitorMode != null) { + track.monitorMode().set(monitorMode.toUpperCase(Locale.ROOT)); + } + if (color != null) { + int[] rgb = parseColor(color, operation); + track.color().set(rgb[0] / 255f, rgb[1] / 255f, rgb[2] / 255f); + } + settle(); + return trackMixerState(trackIndex); + } + + /** Sets a volume parameter (track, drum pad or layer) to a dB value by searching against its display. */ + void setVolumeDb(Parameter vol, double targetDb) { + double lo = 0.0; + double hi = 1.0; + double best = 0.5; + double bestError = Double.MAX_VALUE; + for (int i = 0; i < 16; i++) { + double mid = (lo + hi) / 2; + String before = vol.displayedValue().get(); + vol.value().setImmediately(mid); + waitFor(() -> Math.abs(vol.value().get() - mid) < 1e-6); + waitForChange(() -> vol.displayedValue().get(), before); + Double shown = parseDb(vol.displayedValue().get()); + if (shown != null && Math.abs(shown - targetDb) < bestError) { + bestError = Math.abs(shown - targetDb); + best = mid; + } + if (shown == null || shown < targetDb) { + lo = mid; + } else { + hi = mid; + } + } + // The display is rounded, so keep the position whose displayed value was closest to the target. + double finalValue = best; + vol.value().setImmediately(finalValue); + waitFor(() -> Math.abs(vol.value().get() - finalValue) < 1e-6); + } + + /** Waits briefly for a displayed string to change; displays update after the raw value. */ + private void waitForChange(java.util.function.Supplier display, String before) { + if (timeoutMs <= 0) { + return; + } + long deadline = System.currentTimeMillis() + 300; + while (System.currentTimeMillis() < deadline && java.util.Objects.equals(display.get(), before)) { + sleep(Constants.POLL_INTERVAL_MS); + } + sleep(Constants.POLL_INTERVAL_MS); + } + + static Double parseDb(String display) { + if (display == null) { + return null; + } + String s = display.replace("dB", "").replace('−', '-').trim(); + if (s.toLowerCase(Locale.ROOT).contains("inf")) { + return Double.NEGATIVE_INFINITY; + } + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + return null; + } + } + + static int[] parseColor(String color, String operation) throws BitwigApiException { + String hex = color.startsWith("#") ? color.substring(1) : color; + if (hex.length() != 6) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "color must be #RRGGBB"); + } + try { + return new int[]{ + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16) + }; + } catch (NumberFormatException e) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "color must be #RRGGBB"); + } + } + + public Map trackMixerState(int trackIndex) { + Track track = trackBank.getItemAt(trackIndex); + Map m = new LinkedHashMap<>(); + m.put("track_index", trackIndex); + m.put("name", track.name().get()); + m.put("volume", track.volume().value().get()); + m.put("volume_display", track.volume().displayedValue().get()); + m.put("pan", track.pan().value().get() * 2.0 - 1.0); + m.put("pan_display", track.pan().displayedValue().get()); + m.put("mute", track.mute().get()); + m.put("solo", track.solo().get()); + m.put("arm", track.arm().get()); + m.put("activated", track.isActivated().get()); + m.put("monitor_mode", track.monitorMode().get()); + m.put("crossfade_mode", track.crossFadeMode().get()); + if (track.isGroup().get()) { + m.put("group_expanded", track.isGroupExpanded().get()); + } + m.put("is_master", isMaster(track)); + List> sends = new ArrayList<>(); + SendBank sendBank = sendBankOrNull(track); + for (int i = 0; sendBank != null && i < sendBank.getSizeOfBank(); i++) { + Send send = sendBank.getItemAt(i); + if (!send.exists().get()) { + continue; + } + Map s = new LinkedHashMap<>(); + s.put("index", i); + s.put("name", send.name().get()); + s.put("value", send.value().get()); + s.put("display", send.displayedValue().get()); + s.put("enabled", send.isEnabled().get()); + sends.add(s); + } + m.put("sends", sends); + return m; + } + + /** The track bank is created without sends, in which case Bitwig throws on sendBank(). */ + private static SendBank sendBankOrNull(Track track) { + try { + SendBank bank = track.sendBank(); + bank.getSizeOfBank(); + return bank; + } catch (RuntimeException e) { + return null; + } + } + + public Map setSend(int trackIndex, int sendIndex, Double value, Boolean enabled) throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + SendBank sendBank = sendBankOrNull(track); + if (sendBank == null) { + throw new BitwigApiException(ErrorCode.RESOURCE_UNAVAILABLE, "setSend", "Sends are not available"); + } + if (sendIndex < 0 || sendIndex >= sendBank.getSizeOfBank() || !sendBank.getItemAt(sendIndex).exists().get()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, "setSend", "No send at index " + sendIndex); + } + Send send = sendBank.getItemAt(sendIndex); + if (value != null) { + send.value().setImmediately(value); + } + if (enabled != null) { + send.isEnabled().set(enabled); + } + settle(); + return trackMixerState(trackIndex); + } + + // ======================================== + // Device chains (slots and layers inside a device) + // ======================================== + + /** + * A cursor device plus views into its nested chains. Slots (e.g. a Vocoder's "Modulator" and + * "Carrier", an FX device's "FX") are browsed one at a time through the cursor slot; layers + * (FX Layer, Instrument Layer) each get their own device bank. + */ + private static final class ChainCursor { + final PinnableCursorDevice device; + final DeviceSlot slot; + final DeviceBank slotDevices; + final DeviceLayerBank layers; + final List layerDevices = new ArrayList<>(); + + ChainCursor(PinnableCursorDevice device) { + this.device = device; + this.slot = device.getCursorSlot(); + this.slotDevices = slot.createDeviceBank(Constants.CHAIN_DEVICES); + this.layers = device.createLayerBank(Constants.LAYERS); + device.exists().markInterested(); + device.name().markInterested(); + device.position().markInterested(); + device.slotNames().markInterested(); + device.hasSlots().markInterested(); + device.hasLayers().markInterested(); + device.hasDrumPads().markInterested(); + device.isNested().markInterested(); + slot.name().markInterested(); + markDevices(slotDevices); + for (int i = 0; i < layers.getSizeOfBank(); i++) { + DeviceLayer layer = layers.getItemAt(i); + layer.exists().markInterested(); + layer.name().markInterested(); + DeviceBank bank = layer.createDeviceBank(Constants.CHAIN_DEVICES); + markDevices(bank); + layerDevices.add(bank); + } + } + + private static void markDevices(DeviceBank bank) { + for (int i = 0; i < bank.getSizeOfBank(); i++) { + Device d = bank.getItemAt(i); + d.exists().markInterested(); + d.name().markInterested(); + d.isEnabled().markInterested(); + d.deviceType().markInterested(); + d.presetName().markInterested(); + } + } + + void selectSlot(String name) { + if (slot instanceof CursorDeviceSlot cursorSlot) { + cursorSlot.selectSlot(name); + } + } + } + + /** One chain inside a device: its display name, the chain itself (for insertion) and its devices. */ + private record ChainView(String name, DeviceChain chain, DeviceBank devices) {} + + /** Selects a chain on a cursor that already points at the containing device. */ + private ChainView selectChain(ChainCursor c, String chain, String operation) throws BitwigApiException { + String wanted = chain.trim(); + if (wanted.toLowerCase(Locale.ROOT).startsWith("layer:")) { + String key = wanted.substring("layer:".length()).trim(); + for (int i = 0; i < c.layers.getSizeOfBank(); i++) { + DeviceLayer layer = c.layers.getItemAt(i); + if (layer.exists().get() && (key.equals(String.valueOf(i)) || key.equalsIgnoreCase(layer.name().get()))) { + return new ChainView("layer:" + i, layer, c.layerDevices.get(i)); + } + } + throw new BitwigApiException(ErrorCode.DEVICE_NOT_FOUND, operation, "No layer '" + key + "' in this device"); + } + String[] names = c.device.slotNames().get(); + String match = null; + for (String n : names) { + if (n.equalsIgnoreCase(wanted)) { + match = n; + } + } + if (match == null) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "Device has no chain '" + wanted + "'. Chains: " + String.join(", ", names) + + (c.device.hasLayers().get() ? " (and layers: use 'layer:N')" : "")); + } + String exact = match; + c.selectSlot(exact); + waitFor(() -> exact.equalsIgnoreCase(c.slot.name().get())); + settle(); + return new ChainView(exact, c.slot, c.slotDevices); + } + + private Device chainDevice(ChainView view, int index, String operation) throws BitwigApiException { + if (index < 0 || index >= view.devices().getSizeOfBank() || !view.devices().getItemAt(index).exists().get()) { + throw new BitwigApiException(ErrorCode.DEVICE_NOT_FOUND, operation, + "No device at index " + index + " in chain '" + view.name() + "'"); + } + return view.devices().getItemAt(index); + } + + private static int countBank(DeviceBank bank) { + int n = 0; + for (int i = 0; i < bank.getSizeOfBank(); i++) { + if (bank.getItemAt(i).exists().get()) { + n = i + 1; + } + } + return n; + } + + private static List> summaries(DeviceBank bank) { + List> out = new ArrayList<>(); + for (int i = 0; i < bank.getSizeOfBank(); i++) { + Device d = bank.getItemAt(i); + if (!d.exists().get()) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("index", i); + m.put("name", d.name().get()); + m.put("enabled", d.isEnabled().get()); + m.put("type", d.deviceType().get()); + putPreset(m, d); + out.add(m); + } + return out; + } + + /** Lists the chains (slots and layers) inside a device and the devices in each. */ + public Map getDeviceChains(int trackIndex, int deviceIndex) throws BitwigApiException { + final String operation = "getDeviceChains"; + focusDevice(primary.device, trackIndex, deviceIndex, operation); + Map result = new LinkedHashMap<>(); + result.put("track_index", trackIndex); + result.put("device_index", deviceIndex); + result.put("device", primary.device.name().get()); + List> chains = new ArrayList<>(); + for (String name : primary.device.slotNames().get()) { + ChainView v = selectChain(primary, name, operation); + Map m = new LinkedHashMap<>(); + m.put("chain", v.name()); + m.put("devices", summaries(v.devices())); + chains.add(m); + } + if (primary.device.hasLayers().get()) { + for (int i = 0; i < primary.layers.getSizeOfBank(); i++) { + DeviceLayer layer = primary.layers.getItemAt(i); + if (!layer.exists().get()) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("chain", "layer:" + i); + m.put("layer_name", layer.name().get()); + m.put("devices", summaries(primary.layerDevices.get(i))); + chains.add(m); + } + } + result.put("chains", chains); + result.put("has_drum_pads", primary.device.hasDrumPads().get()); + result.put("slot_switching", primary.slot instanceof CursorDeviceSlot); + return result; + } + + /** + * Points the primary cursor at a device, or at a device inside one of its chains when chain is given. + * The remote-control page then shows that device's parameters. + */ + private void focusTarget(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, String operation) + throws BitwigApiException { + focusDevice(primary.device, trackIndex, deviceIndex, operation); + if (chain == null) { + return; + } + if (chainDeviceIndex == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "chain_device_index is required with chain"); + } + ChainView view = selectChain(primary, chain, operation); + Device nested = chainDevice(view, chainDeviceIndex, operation); + String expected = nested.name().get(); + primary.device.selectDevice(nested); + if (!waitFor(() -> primary.device.isNested().get() && expected.equals(primary.device.name().get()))) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, + "Could not focus '" + expected + "' in chain '" + view.name() + "'"); + } + settle(); + } + + /** Resolves a top-level device, or a device inside a chain of it. */ + private Device resolveTarget(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, String operation) + throws BitwigApiException { + if (chain == null) { + return resolveDevice(trackIndex, deviceIndex, operation); + } + if (chainDeviceIndex == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "chain_device_index is required with chain"); + } + focusDevice(primary.device, trackIndex, deviceIndex, operation); + return chainDevice(selectChain(primary, chain, operation), chainDeviceIndex, operation); + } + + /** Where a device should go: a track's chain, or a chain inside a device (via the given cursor). */ + private record Destination(InsertionPoint point, DeviceBank devices, String chainName) {} + + private Destination destination(ChainCursor cursor, int trackIndex, Integer deviceIndex, String chain, + String position, Integer afterIndex, String operation) throws BitwigApiException { + if (chain == null) { + return new Destination(insertionPoint(trackIndex, position, afterIndex, operation), + trackDeviceBanks.get(trackIndex), null); + } + if (deviceIndex == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "device_index (the device that contains the chain) is required with chain"); + } + focusDevice(cursor.device, trackIndex, deviceIndex, operation); + ChainView view = selectChain(cursor, chain, operation); + InsertionPoint ip; + if (afterIndex != null) { + ip = chainDevice(view, afterIndex, operation).afterDeviceInsertionPoint(); + } else if ("start".equals(position)) { + ip = view.chain().startOfDeviceChainInsertionPoint(); + } else if (position == null || "end".equals(position)) { + ip = view.chain().endOfDeviceChainInsertionPoint(); + } else { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "position must be 'start' or 'end'"); + } + return new Destination(ip, view.devices(), view.name()); + } + + // ======================================== + // Devices + // ======================================== + + public List listBitwigDeviceNames() { + return new ArrayList<>(bitwigDevices.keySet()); + } + + /** Built-in devices with type and category, optionally filtered by a substring of name, type or category. */ + public List> listBitwigDevices(String filter) { + String f = filter == null ? null : filter.toLowerCase(Locale.ROOT); + List> out = new ArrayList<>(); + for (BitwigDevice d : bitwigDevices.values()) { + if (f != null && !(d.name() + " " + d.type() + " " + d.category()).toLowerCase(Locale.ROOT).contains(f)) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("name", d.name()); + m.put("type", d.type()); + m.put("category", d.category()); + out.add(m); + } + out.sort((a, b) -> String.valueOf(a.get("name")).compareToIgnoreCase(String.valueOf(b.get("name")))); + return out; + } + + private InsertionPoint insertionPoint(int trackIndex, String position, Integer afterDeviceIndex, String operation) + throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + if (afterDeviceIndex != null) { + return resolveDevice(trackIndex, afterDeviceIndex, operation).afterDeviceInsertionPoint(); + } + if (position == null || "end".equals(position)) { + return track.endOfDeviceChainInsertionPoint(); + } + if ("start".equals(position)) { + return track.startOfDeviceChainInsertionPoint(); + } + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "position must be 'start' or 'end'"); + } + + public Map insertDevice(int trackIndex, String bitwigDevice, String clapId, String vst3Id, + String position, Integer afterDeviceIndex) throws BitwigApiException { + return insertDevice(trackIndex, null, null, bitwigDevice, clapId, vst3Id, position, afterDeviceIndex); + } + + public Map insertDevice(int trackIndex, Integer deviceIndex, String chain, String bitwigDevice, + String clapId, String vst3Id, String position, Integer afterDeviceIndex) + throws BitwigApiException { + return insertDevice(trackIndex, deviceIndex, chain, bitwigDevice, clapId, vst3Id, null, position, afterDeviceIndex); + } + + public Map insertDevice(int trackIndex, Integer deviceIndex, String chain, String bitwigDevice, + String clapId, String vst3Id, String presetFile, String position, + Integer afterDeviceIndex) throws BitwigApiException { + return insertDevice(trackIndex, deviceIndex, chain, bitwigDevice, clapId, vst3Id, null, presetFile, position, + afterDeviceIndex); + } + + public Map insertDevice(int trackIndex, Integer deviceIndex, String chain, String bitwigDevice, + String clapId, String vst3Id, Integer vst2Id, String presetFile, + String position, Integer afterDeviceIndex) throws BitwigApiException { + final String operation = "insertDevice"; + Destination dest = destination(primary, trackIndex, deviceIndex, chain, position, afterDeviceIndex, operation); + int before = countBank(dest.devices()); + insertInto(dest.point(), bitwigDevice, clapId, vst3Id, vst2Id, presetFile, operation); + boolean added = waitFor(() -> countBank(dest.devices()) > before); + Map result = new LinkedHashMap<>(); + result.put("inserted", added); + if (dest.chainName() != null) { + result.put("chain", dest.chainName()); + } + result.put("devices", dest.chainName() != null ? summaries(dest.devices()) : deviceSummaries(trackIndex)); + return result; + } + + /** Inserts a Bitwig device, a plugin, or a file (.bwpreset, .bwdevice, a plugin preset, or a sample) at a point. */ + void insertInto(InsertionPoint ip, String bitwigDevice, String clapId, String vst3Id, String file, String operation) + throws BitwigApiException { + insertInto(ip, bitwigDevice, clapId, vst3Id, null, file, operation); + } + + void insertInto(InsertionPoint ip, String bitwigDevice, String clapId, String vst3Id, Integer vst2Id, String file, + String operation) throws BitwigApiException { + if (bitwigDevice != null) { + UUID id = findBitwigDevice(bitwigDevice); + if (id == null) { + throw new BitwigApiException(ErrorCode.DEVICE_NOT_FOUND, operation, + "Unknown Bitwig device '" + bitwigDevice + "'. Use list_bitwig_devices."); + } + ip.insertBitwigDevice(id); + } else if (clapId != null) { + ip.insertCLAPDevice(clapId); + } else if (vst3Id != null) { + ip.insertVST3Device(vst3Id); + } else if (vst2Id != null) { + ip.insertVST2Device(vst2Id); + } else if (file != null) { + if (!Files.isRegularFile(Path.of(file))) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "No such file: " + file); + } + ip.insertFile(file); + } else { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "Provide bitwig_device, clap_id, vst3_id, vst2_id or preset_file"); + } + } + + /** + * Replaces a device (or a device inside a chain) with another device or a preset file. Loading a preset + * this way is how a device's sound is swapped while keeping its place in the chain. + */ + public Map replaceDevice(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + String bitwigDevice, String clapId, String vst3Id, String presetFile) + throws BitwigApiException { + return replaceDevice(trackIndex, deviceIndex, chain, chainDeviceIndex, bitwigDevice, clapId, vst3Id, null, + presetFile); + } + + public Map replaceDevice(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + String bitwigDevice, String clapId, String vst3Id, Integer vst2Id, + String presetFile) throws BitwigApiException { + final String operation = "replaceDevice"; + Device device = resolveTarget(trackIndex, deviceIndex, chain, chainDeviceIndex, operation); + String oldName = device.name().get(); + String oldPreset = device.presetName().get(); + insertInto(device.replaceDeviceInsertionPoint(), bitwigDevice, clapId, vst3Id, vst2Id, presetFile, operation); + waitFor(() -> !oldName.equals(device.name().get()) || !oldPreset.equals(device.presetName().get()), 4000); + settle(); + Map result = new LinkedHashMap<>(); + result.put("replaced", oldName); + result.put("device", device.name().get()); + result.put("preset", device.presetName().get()); + result.put("devices", chain == null ? deviceSummaries(trackIndex) : summaries( + selectChain(primary, chain, operation).devices())); + return result; + } + + UUID findBitwigDevice(String name) { + for (Map.Entry e : bitwigDevices.entrySet()) { + if (e.getKey().equalsIgnoreCase(name.trim())) { + return e.getValue().id(); + } + } + return null; + } + + public Map copyOrMoveDevice(int fromTrack, int fromDevice, int toTrack, String position, + Integer afterDeviceIndex, boolean move) throws BitwigApiException { + return copyOrMoveDevice(fromTrack, fromDevice, null, null, toTrack, null, null, position, afterDeviceIndex, move); + } + + /** + * Copies or moves a device. Source and destination can each be a track's chain or a chain inside a + * device; two independent cursors are used so both can point into chains of the same device. + */ + public Map copyOrMoveDevice(int fromTrack, int fromDevice, String fromChain, + Integer fromChainDeviceIndex, int toTrack, Integer toDevice, + String toChain, String position, Integer afterDeviceIndex, + boolean move) throws BitwigApiException { + final String operation = move ? "moveDevice" : "copyDevice"; + // A chain source is held by the primary cursor itself, not by a slot-bank entry: the selected slot + // belongs to the device, so pointing the second cursor at another slot of the same device would + // repoint a bank entry to a different device. + Device source; + if (fromChain != null) { + focusTarget(fromTrack, fromDevice, fromChain, fromChainDeviceIndex, operation); + source = primary.device; + } else { + source = resolveDevice(fromTrack, fromDevice, operation); + } + String sourceName = source.name().get(); + Destination dest = destination(secondary, toTrack, toDevice, toChain, position, afterDeviceIndex, operation); + int before = countBank(dest.devices()); + if (move) { + dest.point().moveDevices(source); + } else { + dest.point().copyDevices(source); + } + if (!waitFor(() -> countBank(dest.devices()) > before)) { + settle(); + } + Map result = new LinkedHashMap<>(); + result.put("device", sourceName); + if (dest.chainName() != null) { + result.put("target_chain", dest.chainName()); + result.put("target_chain_devices", summaries(dest.devices())); + } else { + result.put("target_track_devices", deviceSummaries(toTrack)); + } + return result; + } + + public Map deleteDevice(int trackIndex, int deviceIndex) throws BitwigApiException { + return deleteDevice(trackIndex, deviceIndex, null, null); + } + + public Map deleteDevice(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex) + throws BitwigApiException { + final String operation = "deleteDevice"; + Device device = resolveTarget(trackIndex, deviceIndex, chain, chainDeviceIndex, operation); + String name = device.name().get(); + DeviceBank bank = chain == null ? trackDeviceBanks.get(trackIndex) + : (chain.toLowerCase(Locale.ROOT).startsWith("layer:") + ? primary.layerDevices.get(layerIndexOf(device)) : primary.slotDevices); + int before = countBank(bank); + device.deleteObject(); + waitFor(() -> countBank(bank) < before); + Map result = new LinkedHashMap<>(); + result.put("deleted_device", name); + result.put("devices", chain == null ? deviceSummaries(trackIndex) : summaries(bank)); + return result; + } + + private int layerIndexOf(Device device) { + for (int i = 0; i < primary.layerDevices.size(); i++) { + DeviceBank bank = primary.layerDevices.get(i); + for (int d = 0; d < bank.getSizeOfBank(); d++) { + if (bank.getItemAt(d) == device) { + return i; + } + } + } + return 0; + } + + public Map setDeviceEnabled(int trackIndex, int deviceIndex, boolean enabled) throws BitwigApiException { + return setDeviceEnabled(trackIndex, deviceIndex, null, null, enabled); + } + + public Map setDeviceEnabled(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + boolean enabled) throws BitwigApiException { + Device device = resolveTarget(trackIndex, deviceIndex, chain, chainDeviceIndex, "setDeviceEnabled"); + device.isEnabled().set(enabled); + waitFor(() -> device.isEnabled().get() == enabled); + return Map.of("device", device.name().get(), "enabled", device.isEnabled().get()); + } + + public Map setDeviceWindowOpen(int trackIndex, int deviceIndex, boolean open) throws BitwigApiException { + Device device = resolveDevice(trackIndex, deviceIndex, "setDeviceWindowOpen"); + device.isWindowOpen().set(open); + return Map.of("device", device.name().get(), "window_open", open); + } + + /** Points the private edit cursor at a device without touching the user's selection. */ + void focusDevice(PinnableCursorDevice cursor, int trackIndex, int deviceIndex, String operation) + throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + Device device = resolveDevice(trackIndex, deviceIndex, operation); + String expectedName = device.name().get(); + editTrack.selectChannel(track); + waitFor(() -> track.name().get().equals(editTrack.name().get())); + cursor.selectDevice(device); + boolean ok = waitFor(() -> cursor.exists().get() + && !cursor.isNested().get() + && cursor.position().get() == deviceIndex + && expectedName.equals(cursor.name().get())); + if (!ok) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, + "Could not focus device " + deviceIndex + " on track " + trackIndex); + } + settle(); + } + + private boolean selectPage(int page) { + if (editPage.selectedPageIndex().get() == page) { + return true; + } + editPage.selectedPageIndex().set(page); + boolean ok = waitFor(() -> editPage.selectedPageIndex().get() == page); + settle(); + return ok; + } + + private List> readPage() { + List> params = new ArrayList<>(); + for (int i = 0; i < editPage.getParameterCount(); i++) { + RemoteControl p = editPage.getParameter(i); + if (!p.exists().get()) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("index", i); + m.put("name", p.name().get()); + m.put("value", p.value().get()); + m.put("display", p.displayedValue().get()); + // Modulators (LFOs, envelopes, macros) move the effective value away from the knob position. + double modulated = p.modulatedValue().get(); + if (Math.abs(modulated - p.value().get()) > 1e-4) { + m.put("modulated_value", modulated); + m.put("modulated_display", p.modulatedValue().displayedValue().get()); + } + params.add(m); + } + return params; + } + + /** Reads one page, or every page when page is null. */ + public Map getDeviceParameters(int trackIndex, int deviceIndex, Integer page) throws BitwigApiException { + return getDeviceParameters(trackIndex, deviceIndex, null, null, page); + } + + public Map getDeviceParameters(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + Integer page) throws BitwigApiException { + final String operation = "getDeviceParameters"; + focusTarget(trackIndex, deviceIndex, chain, chainDeviceIndex, operation); + String[] pageNames = editPage.pageNames().get(); + Map result = new LinkedHashMap<>(); + result.put("track_index", trackIndex); + result.put("device_index", deviceIndex); + if (chain != null) { + result.put("chain", chain); + result.put("chain_device_index", chainDeviceIndex); + } + result.put("device", editDevice.name().get()); + result.put("page_names", pageNames); + List> pages = new ArrayList<>(); + int first = page == null ? 0 : page; + int last = page == null ? pageNames.length - 1 : page; + if (page != null && (page < 0 || page >= Math.max(1, pageNames.length))) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "page must be 0.." + (pageNames.length - 1)); + } + for (int p = first; p <= Math.max(first, last); p++) { + if (pageNames.length > 0) { + selectPage(p); + } + Map pm = new LinkedHashMap<>(); + pm.put("page", p); + pm.put("page_name", pageNames.length > p ? pageNames[p] : ""); + pm.put("parameters", readPage()); + pages.add(pm); + } + result.put("pages", pages); + return result; + } + + /** A single parameter assignment: by page + index, or by name (searched across all pages). */ + public record ParamAssignment(Integer page, Integer index, String name, double value) {} + + public Map setDeviceParameters(int trackIndex, int deviceIndex, List assignments) + throws BitwigApiException { + return setDeviceParameters(trackIndex, deviceIndex, null, null, assignments); + } + + public Map setDeviceParameters(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + List assignments) throws BitwigApiException { + return setDeviceParameters(trackIndex, deviceIndex, chain, chainDeviceIndex, assignments, true); + } + + /** + * With verify=false values are written without waiting for each to be confirmed (one short settle per + * page instead), for bulk writes such as sweeping every control of a device. + */ + public Map setDeviceParameters(int trackIndex, int deviceIndex, String chain, Integer chainDeviceIndex, + List assignments, boolean verify) + throws BitwigApiException { + final String operation = "setDeviceParameters"; + focusTarget(trackIndex, deviceIndex, chain, chainDeviceIndex, operation); + String[] pageNames = editPage.pageNames().get(); + int pageCount = Math.max(1, pageNames.length); + List> results = new ArrayList<>(); + for (ParamAssignment a : assignments) { + Map r = new LinkedHashMap<>(); + r.put("request", a.name() != null ? a.name() : ("page " + a.page() + " index " + a.index())); + if (a.value() < 0.0 || a.value() > 1.0) { + r.put("status", "error"); + r.put("message", "value must be 0.0-1.0"); + results.add(r); + continue; + } + RemoteControl target = null; + int foundPage = -1; + if (a.name() != null) { + for (int p = 0; p < pageCount && target == null; p++) { + selectPage(p); + for (int i = 0; i < editPage.getParameterCount(); i++) { + RemoteControl rc = editPage.getParameter(i); + if (rc.exists().get() && a.name().equalsIgnoreCase(rc.name().get())) { + target = rc; + foundPage = p; + break; + } + } + } + } else if (a.index() != null) { + int p = a.page() == null ? 0 : a.page(); + if (p >= 0 && p < pageCount && a.index() >= 0 && a.index() < editPage.getParameterCount()) { + selectPage(p); + RemoteControl rc = editPage.getParameter(a.index()); + if (rc.exists().get()) { + target = rc; + foundPage = p; + } + } + } + if (target == null) { + r.put("status", "error"); + r.put("message", "Parameter not found"); + results.add(r); + continue; + } + RemoteControl t = target; + // Bitwig ignores set() on remote controls from extensions with no bound hardware; setImmediately() applies. + double before = t.value().get(); + t.value().setImmediately(a.value()); + if (verify) { + // Stepped controls snap to the nearest step, so "applied" also accepts any change. + boolean applied = waitFor(() -> Math.abs(t.value().get() - a.value()) < 1e-3 + || Math.abs(t.value().get() - before) > 1e-6, 400); + settle(); + r.put("applied", applied || Math.abs(before - a.value()) < 1e-3); + } + r.put("status", "success"); + r.put("page", foundPage); + r.put("name", t.name().get()); + r.put("value", t.value().get()); + r.put("display", t.displayedValue().get()); + results.add(r); + } + Map result = new LinkedHashMap<>(); + result.put("device", editDevice.name().get()); + result.put("results", results); + return result; + } + + // ======================================== + // Clips and files + // ======================================== + + public Map insertFile(int trackIndex, Integer slotIndex, String path) throws BitwigApiException { + final String operation = "insertFile"; + if (path == null || !Files.isRegularFile(Path.of(path))) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "File not found: " + path); + } + Track track = trackBank.getItemAt(trackIndex); + Map result = new LinkedHashMap<>(); + if (slotIndex != null) { + ClipLauncherSlot slot = slotAt(track, slotIndex, operation); + int tracksBefore = countTracks(); + slot.replaceInsertionPoint().insertFile(path); + boolean ok = waitFor(() -> slot.hasContent().get() || countTracks() > tracksBefore); + settle(); + result.put("track_index", trackIndex); + result.put("slot_index", slotIndex); + result.put("inserted", slot.hasContent().get()); + int created = countTracks() - tracksBefore; + if (created > 0) { + // A MIDI file with several tracks or channels is split by Bitwig into one new track each. + result.put("new_tracks_created", created); + result.put("note", "Bitwig split the file into new tracks instead of filling the slot; " + + "merge the MIDI into one track on one channel to import it as a single clip"); + } + } else { + int before = countTracks(); + track.afterTrackInsertionPoint().insertFile(path); + boolean ok = waitFor(() -> countTracks() > before); + result.put("new_track_index", ok ? trackIndex + 1 : null); + result.put("inserted", ok); + } + return result; + } + + private ClipLauncherSlot slotAt(Track track, int slotIndex, String operation) throws BitwigApiException { + ClipLauncherSlotBank slots = track.clipLauncherSlotBank(); + if (slotIndex < 0 || slotIndex >= slots.getSizeOfBank()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "slot_index must be 0.." + (slots.getSizeOfBank() - 1)); + } + return slots.getItemAt(slotIndex); + } + + public Map createClip(int trackIndex, int slotIndex, int lengthBeats) throws BitwigApiException { + final String operation = "createClip"; + ClipLauncherSlot slot = slotAt(trackBank.getItemAt(trackIndex), slotIndex, operation); + if (slot.hasContent().get()) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "Slot already has a clip"); + } + slot.createEmptyClip(lengthBeats); + boolean ok = waitFor(() -> slot.hasContent().get()); + return Map.of("track_index", trackIndex, "slot_index", slotIndex, "length_beats", lengthBeats, "created", ok); + } + + public Map deleteClip(int trackIndex, int slotIndex) throws BitwigApiException { + ClipLauncherSlot slot = slotAt(trackBank.getItemAt(trackIndex), slotIndex, "deleteClip"); + slot.deleteObject(); + waitFor(() -> !slot.hasContent().get()); + return Map.of("track_index", trackIndex, "slot_index", slotIndex, "deleted", !slot.hasContent().get()); + } + + /** A MIDI note in beats from the clip start. */ + public record Note(double startBeats, int pitch, int velocity, double durationBeats) {} + + public Map writeNotes(int trackIndex, int slotIndex, List notes, boolean clearFirst) + throws BitwigApiException { + final String operation = "writeNotes"; + Track track = trackBank.getItemAt(trackIndex); + ClipLauncherSlot slot = slotAt(track, slotIndex, operation); + if (!slot.hasContent().get()) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, operation, "Slot is empty; create_clip first"); + } + editTrack.selectChannel(track); + waitFor(() -> track.name().get().equals(editTrack.name().get())); + slot.select(); + if (!waitFor(() -> editClip.exists().get())) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, "Could not focus the clip"); + } + settle(); + if (clearFirst) { + editClip.clearSteps(); + } + int written = 0; + for (Note n : notes) { + int step = (int) Math.round(n.startBeats() / Constants.CLIP_STEP_BEATS); + if (step < 0 || step >= Constants.CLIP_GRID_WIDTH || n.pitch() < 0 || n.pitch() > 127) { + continue; + } + editClip.setStep(step, n.pitch(), Math.max(1, Math.min(127, n.velocity())), n.durationBeats()); + written++; + } + return Map.of("track_index", trackIndex, "slot_index", slotIndex, "notes_written", written); + } + + // ======================================== + // Transport + // ======================================== + + public Map setTransport(Double tempoBpm, Double positionBeats, Boolean loop, Boolean metronome, + Boolean record, Boolean overdub) { + if (tempoBpm != null) { + transport.tempo().setRaw(tempoBpm); + } + if (positionBeats != null) { + transport.getPosition().set(positionBeats); + } + if (loop != null) { + transport.isArrangerLoopEnabled().set(loop); + } + if (metronome != null) { + transport.isMetronomeEnabled().set(metronome); + } + if (record != null) { + transport.isArrangerRecordEnabled().set(record); + } + if (overdub != null) { + transport.isArrangerOverdubEnabled().set(overdub); + } + settle(); + Map m = new LinkedHashMap<>(); + m.put("tempo", transport.tempo().displayedValue().get()); + m.put("position_beats", transport.getPosition().get()); + m.put("loop", transport.isArrangerLoopEnabled().get()); + m.put("metronome", transport.isMetronomeEnabled().get()); + m.put("record", transport.isArrangerRecordEnabled().get()); + m.put("overdub", transport.isArrangerOverdubEnabled().get()); + return m; + } + + // ======================================== + // Application actions + // ======================================== + + Track trackAt(int trackIndex) { + return trackBank.getItemAt(trackIndex); + } + + /** + * Brings things into view in Bitwig's window: a panel layout (ARRANGE, MIX, EDIT), a track, a launcher clip in + * the editor, or a device. This changes what the user sees and selects; it is for working alongside them. + */ + public Map show(String panelLayout, Integer trackIndex, Integer slotIndex, Integer deviceIndex) + throws BitwigApiException { + final String operation = "show"; + Map m = new LinkedHashMap<>(); + if (panelLayout != null) { + String layout = panelLayout.toUpperCase(Locale.ROOT); + if (!List.of("ARRANGE", "MIX", "EDIT").contains(layout)) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "panel_layout must be ARRANGE, MIX or EDIT"); + } + application.setPanelLayout(layout); + waitFor(() -> layout.equalsIgnoreCase(application.panelLayout().get())); + m.put("panel_layout", application.panelLayout().get()); + } + if (trackIndex != null) { + Track track = trackBank.getItemAt(trackIndex); + track.selectInMixer(); + track.makeVisibleInArranger(); + track.makeVisibleInMixer(); + m.put("track", track.name().get()); + if (slotIndex != null) { + ClipLauncherSlot slot = slotAt(track, slotIndex, operation); + if (!slot.hasContent().get()) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, operation, "Slot " + slotIndex + " is empty"); + } + slot.select(); + slot.showInEditor(); + m.put("clip_slot", slotIndex); + } + if (deviceIndex != null) { + Device device = resolveDevice(trackIndex, deviceIndex, operation); + device.selectInEditor(); + m.put("device", device.name().get()); + } + } else if (slotIndex != null || deviceIndex != null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, "A clip or device needs its track"); + } + settle(); + return m; + } + + /** The private cursor track other facades attach their own cursor devices to (during init). */ + CursorTrack editTrack() { + return editTrack; + } + + public Application application() { + return application; + } + + public boolean isPlaying() { + return transport.isPlaying().get(); + } + + public List> listActions(String filter) { + List> out = new ArrayList<>(); + String f = filter == null ? null : filter.toLowerCase(Locale.ROOT); + for (Action a : application.getActions()) { + String id = a.getId(); + String name = a.getName(); + String cat = a.getCategory() != null ? a.getCategory().getName() : ""; + if (f != null && !(id + " " + name + " " + cat).toLowerCase(Locale.ROOT).contains(f)) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("id", id); + m.put("name", name); + m.put("category", cat); + out.add(m); + } + return out; + } + + public Map invokeAction(String id) throws BitwigApiException { + Action action = application.getAction(id); + if (action == null) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, "invokeAction", "Unknown action id '" + id + "'. Use list_actions."); + } + action.invoke(); + return Map.of("invoked", id, "name", action.getName()); + } + + public Map undo(boolean redo) { + if (redo) { + application.redo(); + } else { + application.undo(); + } + settle(); + return Map.of("action", redo ? "redo" : "undo"); + } + + // ======================================== + // Device list resource + // ======================================== + + /** A Bitwig built-in device: name, UUID, and (when read from Bitwig's library) type and category. */ + public record BitwigDevice(String name, UUID id, String type, String category) {} + + /** + * Bitwig's built-in devices. Read from Bitwig's own library (Library/device-settings under the + * installationRoot Bitwig passes to the JVM) so every device of the running version is insertable, + * falling back to the bundled list when that is unavailable. + */ + private static Map loadBitwigDevices() { + Map map = new LinkedHashMap<>(); + try (InputStream in = BitwigEditFacade.class.getResourceAsStream(Constants.DEVICE_LIST_RESOURCE)) { + if (in != null) { + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + int sep = line.lastIndexOf('$'); + if (line.isEmpty() || line.startsWith("#") || sep < 0) { + continue; + } + String name = line.substring(0, sep); + map.put(name, new BitwigDevice(name, UUID.fromString(line.substring(sep + 1)), "", "")); + } + } + } catch (Exception e) { + // Keep whatever was read; the library scan below may still fill the map. + } + String root = System.getProperty("installationRoot"); + if (root != null) { + map.putAll(readLibrary(Path.of(root, "Library", "device-settings"))); + } + return map; + } + + static Map readLibrary(Path deviceSettings) { + Map map = new LinkedHashMap<>(); + if (!Files.isDirectory(deviceSettings)) { + return map; + } + try (var dirs = Files.list(deviceSettings)) { + for (Path dir : (Iterable) dirs::iterator) { + Path preset = dir.resolve("Default.bwpreset"); + if (!Files.isRegularFile(preset)) { + continue; + } + byte[] head; + try (InputStream in = Files.newInputStream(preset)) { + head = in.readNBytes(4096); + } + String id = presetMeta(head, "device_id"); + String name = presetMeta(head, "device_name"); + if (id == null || name == null) { + continue; + } + String type = switch (String.valueOf(presetMeta(head, "device_type"))) { + case "audio_to_audio" -> "audio effect"; + case "note_to_audio" -> "instrument"; + case "note_to_note" -> "note effect"; + default -> ""; + }; + String category = presetMeta(head, "device_category"); + map.put(name, new BitwigDevice(name, UUID.fromString(id), type, category == null ? "" : category)); + } + } catch (Exception e) { + // An unreadable library leaves the bundled list in place. + } + return map; + } + + /** Preset metadata: a 4-byte big-endian length and the key, then 0x08 and a length-prefixed UTF-8 value. */ + static String presetMeta(byte[] head, String key) { + byte[] k = key.getBytes(StandardCharsets.UTF_8); + outer: + for (int i = 0; i + 4 + k.length + 5 <= head.length; i++) { + if (head[i] != 0 || head[i + 1] != 0 || head[i + 2] != 0 || head[i + 3] != k.length) { + continue; + } + for (int j = 0; j < k.length; j++) { + if (head[i + 4 + j] != k[j]) { + continue outer; + } + } + int v = i + 4 + k.length; + if (head[v] != 0x08) { + return null; + } + int n = ((head[v + 1] & 0xff) << 24) | ((head[v + 2] & 0xff) << 16) | ((head[v + 3] & 0xff) << 8) | (head[v + 4] & 0xff); + if (n < 0 || v + 5 + n > head.length) { + return null; + } + return new String(head, v + 5, n, StandardCharsets.UTF_8); + } + return null; + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigEventStream.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigEventStream.java new file mode 100644 index 00000000..907f4731 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigEventStream.java @@ -0,0 +1,170 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Turns Bitwig's change callbacks into a numbered event log that MCP clients can follow (get_events, watch): + * transport state, the project, each track's name/mute/solo/arm/volume/pan, launcher clip playback, the user's + * selected track and device, and the controls of the selected device's remote-control page. Observers are + * registered in the constructor (during init()); the burst of initial values Bitwig sends then is ignored. + */ +public class BitwigEventStream { + + private static final class Constants { + static final int CAPACITY = 2000; + static final long COALESCE_MS = 250; + static final long SETTLE_AFTER_INIT_MS = 2000; + static final int REMOTE_CONTROLS = 8; + private Constants() {} + } + + public static final List TYPES = List.of("transport", "project", "track", "clip", "selection", "parameter"); + + private static final String[] CLIP_STATES = {"stopped", "playing", "recording"}; + + private final EventBuffer buffer = new EventBuffer(Constants.CAPACITY, Constants.COALESCE_MS); + private final Transport transport; + private final TrackBank trackBank; + private final CursorTrack selectedTrack; + private final PinnableCursorDevice selectedDevice; + private final CursorRemoteControlsPage selectedPage; + private volatile boolean live; + + public BitwigEventStream(ControllerHost host, Logger logger, Application application, Transport transport, + TrackBank trackBank) { + this.transport = transport; + this.trackBank = trackBank; + + transport.isPlaying().addValueObserver(v -> emit("transport", "transport:playing", "playing", v)); + transport.isArrangerRecordEnabled().addValueObserver(v -> emit("transport", "transport:recording", "recording", v)); + transport.isArrangerLoopEnabled().addValueObserver(v -> emit("transport", "transport:loop", "loop", v)); + transport.isMetronomeEnabled().addValueObserver(v -> emit("transport", "transport:metronome", "metronome", v)); + transport.tempo().displayedValue().addValueObserver(v -> emit("transport", "transport:tempo", "tempo", v)); + transport.timeSignature().addValueObserver(v -> emit("transport", "transport:time_signature", "time_signature", v)); + transport.getPosition().markInterested(); + + application.projectName().addValueObserver(v -> emit("project", "project:name", "project", v)); + application.hasActiveEngine().addValueObserver(v -> emit("project", "project:engine", "audio_engine_active", v)); + + for (int t = 0; t < trackBank.getSizeOfBank(); t++) { + observeTrack(trackBank.getItemAt(t), t); + } + + this.selectedTrack = host.createCursorTrack("WIGAI_EVENTS_TRACK", "WigAI events (selection)", 0, 0, true); + selectedTrack.name().addValueObserver(v -> emit("selection", "selection:track", "track", v)); + this.selectedDevice = selectedTrack.createCursorDevice("WIGAI_EVENTS_DEVICE", "WigAI events device", 0, + CursorDeviceFollowMode.FOLLOW_SELECTION); + selectedDevice.name().addValueObserver(v -> emit("selection", "selection:device", "device", v)); + selectedDevice.presetName().addValueObserver(v -> emit("selection", "selection:preset", "preset", v)); + this.selectedPage = selectedDevice.createCursorRemoteControlsPage(Constants.REMOTE_CONTROLS); + selectedPage.getName().markInterested(); + for (int i = 0; i < selectedPage.getParameterCount(); i++) { + RemoteControl p = selectedPage.getParameter(i); + p.name().markInterested(); + int index = i; + p.displayedValue().addValueObserver(v -> { + if (!p.name().get().isEmpty()) { + Map d = new LinkedHashMap<>(); + d.put("device", selectedDevice.name().get()); + d.put("page", selectedPage.getName().get()); + d.put("index", index); + d.put("name", p.name().get()); + d.put("value", v); + emitData("parameter", "parameter:" + index, d); + } + }); + } + + host.scheduleTask(() -> { + live = true; + logger.info("BitwigEventStream: recording changes"); + }, Constants.SETTLE_AFTER_INIT_MS); + } + + private void observeTrack(Track track, int t) { + String k = "track:" + t + ":"; + track.name().addValueObserver(v -> emitTrack(t, k + "name", "name", v)); + track.mute().addValueObserver(v -> emitTrack(t, k + "mute", "mute", v)); + track.solo().addValueObserver(v -> emitTrack(t, k + "solo", "solo", v)); + track.arm().addValueObserver(v -> emitTrack(t, k + "arm", "arm", v)); + track.volume().displayedValue().addValueObserver(v -> emitTrack(t, k + "volume", "volume", v)); + track.pan().displayedValue().addValueObserver(v -> emitTrack(t, k + "pan", "pan", v)); + track.exists().addValueObserver(v -> emitTrack(t, k + "exists", "exists", v)); + track.clipLauncherSlotBank().addPlaybackStateObserver((slot, state, queued) -> { + Map d = new LinkedHashMap<>(); + d.put("track_index", t); + d.put("track", track.name().get()); + d.put("slot_index", slot); + d.put("state", state >= 0 && state < CLIP_STATES.length ? CLIP_STATES[state] : String.valueOf(state)); + d.put("queued", queued); + emitData("clip", "clip:" + t + ":" + slot, d); + }); + track.clipLauncherSlotBank().addHasContentObserver((slot, has) -> { + Map d = new LinkedHashMap<>(); + d.put("track_index", t); + d.put("track", track.name().get()); + d.put("slot_index", slot); + d.put("has_clip", has); + emitData("clip", "clip_content:" + t + ":" + slot, d); + }); + } + + private void emitTrack(int t, String key, String field, Object value) { + Map d = new LinkedHashMap<>(); + d.put("track_index", t); + d.put("track", trackBank.getItemAt(t).name().get()); + d.put("field", field); + d.put("value", value); + emitData("track", key, d); + } + + private void emit(String type, String key, String field, Object value) { + Map d = new LinkedHashMap<>(); + d.put(field, value); + emitData(type, key, d); + } + + private void emitData(String type, String key, Map data) { + if (live) { + buffer.add(type, key, data); + } + } + + public EventBuffer buffer() { + return buffer; + } + + /** What is true right now, for context next to a batch of events. */ + public Map now() { + Map m = new LinkedHashMap<>(); + m.put("playing", transport.isPlaying().get()); + m.put("position_beats", transport.getPosition().get()); + m.put("selected_track", selectedTrack.name().get()); + m.put("selected_device", selectedDevice.name().get()); + return m; + } + + /** + * Events after {@code since} (types filter optional), waiting up to {@code waitMs} for one. Without + * {@code since}, nothing is returned and next_seq marks the starting point. + */ + public Map events(Long since, Collection types, long waitMs) throws InterruptedException { + long from = since == null ? buffer.latestSeq() : since; + List found = since == null ? List.of() : buffer.since(from, types, waitMs); + long next = found.isEmpty() ? from : found.get(found.size() - 1).seq(); + Map m = new LinkedHashMap<>(); + m.put("events", found.stream().map(EventBuffer.Event::toMap).toList()); + m.put("next_seq", next); + if (since != null && buffer.missedSince(since)) { + m.put("missed_events", true); + } + m.put("now", now()); + return m; + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java new file mode 100644 index 00000000..1f67eb63 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigProjectFacade.java @@ -0,0 +1,250 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; + +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +/** + * Project-wide operations: solo/mute/arm resets, scenes, groove, transport settings beyond play/stop, + * and copying, moving or duplicating launcher clips. Bitwig objects are created in + * the constructor (during init()). + */ +public class BitwigProjectFacade { + + private final Logger logger; + private final Project project; + private final Groove groove; + private final Transport transport; + private final TrackBank trackBank; + private final BitwigEditFacade edit; + + public BitwigProjectFacade(ControllerHost host, Logger logger, Transport transport, TrackBank trackBank, + BitwigEditFacade edit) { + this.logger = logger; + this.transport = transport; + this.trackBank = trackBank; + this.edit = edit; + this.project = host.getProject(); + this.groove = host.createGroove(); + project.hasSoloedTracks().markInterested(); + project.hasMutedTracks().markInterested(); + project.hasArmedTracks().markInterested(); + project.isModified().markInterested(); + project.cueVolume().value().markInterested(); + project.cueVolume().displayedValue().markInterested(); + project.cueMix().value().markInterested(); + project.cueMix().displayedValue().markInterested(); + for (Parameter p : new Parameter[]{groove.getEnabled(), groove.getShuffleAmount(), groove.getShuffleRate(), + groove.getAccentAmount(), groove.getAccentRate(), groove.getAccentPhase()}) { + p.value().markInterested(); + p.displayedValue().markInterested(); + p.name().markInterested(); + } + transport.isPunchInEnabled().markInterested(); + transport.isPunchOutEnabled().markInterested(); + transport.metronomeVolume().markInterested(); + transport.metronomeVolume().displayedValue().markInterested(); + transport.preRoll().markInterested(); + transport.automationWriteMode().markInterested(); + transport.isClipLauncherOverdubEnabled().markInterested(); + transport.defaultLaunchQuantization().markInterested(); + transport.clipLauncherPostRecordingAction().markInterested(); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ======================================== + // Project-wide state + // ======================================== + + public Map projectState() { + Map m = new LinkedHashMap<>(); + m.put("has_soloed_tracks", project.hasSoloedTracks().get()); + m.put("has_muted_tracks", project.hasMutedTracks().get()); + m.put("has_armed_tracks", project.hasArmedTracks().get()); + m.put("modified", project.isModified().get()); + m.put("cue_volume", project.cueVolume().displayedValue().get()); + m.put("cue_mix", project.cueMix().displayedValue().get()); + m.put("cue_volume_raw", project.cueVolume().value().get()); + m.put("cue_mix_raw", project.cueMix().value().get()); + return m; + } + + /** Clears every solo, mute and/or arm in the project; a stray solo silences everything else. */ + public Map resetProject(boolean unsolo, boolean unmute, boolean unarm) { + if (unsolo) { + project.unsoloAll(); + } + if (unmute) { + project.unmuteAll(); + } + if (unarm) { + project.unarmAll(); + } + sleep(200); + return projectState(); + } + + public Map createScene() { + project.createScene(); + sleep(200); + return Map.of("action", "scene_created"); + } + + /** Cue (headphone) bus: volume and the cue/master mix, both 0-1. */ + public Map setCue(Double volume, Double mix) { + if (volume != null) { + project.cueVolume().value().setImmediately(volume); + } + if (mix != null) { + project.cueMix().value().setImmediately(mix); + } + sleep(150); + return projectState(); + } + + // ======================================== + // Groove + // ======================================== + + public Map setGroove(Boolean enabled, Double shuffleAmount, Double shuffleRate, Double accentAmount, + Double accentRate, Double accentPhase) { + if (enabled != null) { + groove.getEnabled().value().setImmediately(enabled ? 1.0 : 0.0); + } + set(groove.getShuffleAmount(), shuffleAmount); + set(groove.getShuffleRate(), shuffleRate); + set(groove.getAccentAmount(), accentAmount); + set(groove.getAccentRate(), accentRate); + set(groove.getAccentPhase(), accentPhase); + sleep(150); + Map m = new LinkedHashMap<>(); + for (Parameter p : new Parameter[]{groove.getEnabled(), groove.getShuffleAmount(), groove.getShuffleRate(), + groove.getAccentAmount(), groove.getAccentRate(), groove.getAccentPhase()}) { + m.put(p.name().get(), Map.of("value", p.value().get(), "display", p.displayedValue().get())); + } + return m; + } + + private static void set(Parameter p, Double v) { + if (v != null) { + p.value().setImmediately(v); + } + } + + // ======================================== + // Transport settings + // ======================================== + + public Map setTransportOptions(Boolean punchIn, Boolean punchOut, Double metronomeVolume, + String preRoll, String automationWriteMode, Boolean launcherOverdub, + String defaultLaunchQuantization, String postRecordingAction, + String command) throws BitwigApiException { + if (punchIn != null) { + transport.isPunchInEnabled().set(punchIn); + } + if (punchOut != null) { + transport.isPunchOutEnabled().set(punchOut); + } + if (metronomeVolume != null) { + transport.metronomeVolume().setImmediately(metronomeVolume); + } + if (preRoll != null) { + transport.preRoll().set(preRoll); + } + if (automationWriteMode != null) { + transport.automationWriteMode().set(automationWriteMode); + } + if (launcherOverdub != null) { + transport.isClipLauncherOverdubEnabled().set(launcherOverdub); + } + if (defaultLaunchQuantization != null) { + transport.defaultLaunchQuantization().set(defaultLaunchQuantization); + } + if (postRecordingAction != null) { + transport.clipLauncherPostRecordingAction().set(postRecordingAction); + } + if (command != null) { + switch (command.toLowerCase(Locale.ROOT)) { + case "rewind" -> transport.rewind(); + case "fast_forward" -> transport.fastForward(); + case "tap_tempo" -> transport.tapTempo(); + case "continue" -> transport.continuePlayback(); + case "record" -> transport.record(); + default -> throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, "setTransportOptions", + "command must be rewind, fast_forward, tap_tempo, continue or record"); + } + } + sleep(150); + Map m = new LinkedHashMap<>(); + m.put("punch_in", transport.isPunchInEnabled().get()); + m.put("punch_out", transport.isPunchOutEnabled().get()); + m.put("metronome_volume", transport.metronomeVolume().displayedValue().get()); + m.put("pre_roll", transport.preRoll().get()); + m.put("automation_write_mode", transport.automationWriteMode().get()); + m.put("launcher_overdub", transport.isClipLauncherOverdubEnabled().get()); + m.put("default_launch_quantization", transport.defaultLaunchQuantization().get()); + m.put("post_recording_action", transport.clipLauncherPostRecordingAction().get()); + return m; + } + + // ======================================== + // Launcher clips: duplicate, copy, move, record + // ======================================== + + private ClipLauncherSlot slot(int trackIndex, int slotIndex, String operation) throws BitwigApiException { + ClipLauncherSlotBank slots = trackBank.getItemAt(trackIndex).clipLauncherSlotBank(); + if (slotIndex < 0 || slotIndex >= slots.getSizeOfBank()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "slot_index must be 0.." + (slots.getSizeOfBank() - 1)); + } + return slots.getItemAt(slotIndex); + } + + public Map copyOrMoveClip(int fromTrack, int fromSlot, int toTrack, int toSlot, boolean move) + throws BitwigApiException { + final String operation = move ? "moveClip" : "copyClip"; + ClipLauncherSlot source = slot(fromTrack, fromSlot, operation); + ClipLauncherSlot target = slot(toTrack, toSlot, operation); + if (!source.hasContent().get()) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, operation, "Source slot is empty"); + } + if (target.hasContent().get()) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, "Target slot already has a clip"); + } + if (move) { + target.replaceInsertionPoint().moveSlotsOrScenes(source); + } else { + target.replaceInsertionPoint().copySlotsOrScenes(source); + } + edit.waitFor(() -> target.hasContent().get()); + return Map.of("action", move ? "moved" : "copied", "target_has_clip", target.hasContent().get(), + "source_has_clip", source.hasContent().get()); + } + + public Map duplicateClip(int trackIndex, int slotIndex) throws BitwigApiException { + ClipLauncherSlot s = slot(trackIndex, slotIndex, "duplicateClip"); + if (!s.hasContent().get()) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, "duplicateClip", "Slot is empty"); + } + s.duplicateClip(); + sleep(300); + return Map.of("action", "duplicated", "note", "Bitwig places the copy in the next free slot below"); + } + + public Map recordIntoSlot(int trackIndex, int slotIndex) throws BitwigApiException { + ClipLauncherSlot s = slot(trackIndex, slotIndex, "recordIntoSlot"); + s.record(); + return Map.of("action", "recording_queued", "note", "The track must be armed; stop with stop_track or transport_stop"); + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java b/src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java new file mode 100644 index 00000000..3a7139e8 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/BitwigSessionFacade.java @@ -0,0 +1,825 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicIntegerArray; +import java.util.function.BooleanSupplier; + +/** + * Facade for listening to and auditioning a project: track levels, cue markers, the arranger loop and + * time signature, reading and editing launcher clips, playing notes into a track, and scene names. + * Like {@link BitwigEditFacade}, every Bitwig object is created in the constructor (during init()). + */ +public class BitwigSessionFacade { + + private static final class Constants { + public static final int METER_RANGE = 1000; + public static final int CUE_MARKERS = 64; + public static final int SCENES = 128; + public static final int CLIP_GRID_WIDTH = 16384; + public static final int CLIP_GRID_HEIGHT = 128; + public static final double CLIP_STEP_BEATS = 1.0 / 16.0; + public static final long POLL_INTERVAL_MS = 20; + public static final long CLIP_LOAD_MS = 500; + + private Constants() {} + } + + private final ControllerHost host; + private final Logger logger; + private final Transport transport; + private final TrackBank trackBank; + private final MasterTrack master; + private final AtomicIntegerArray peak; + private final AtomicIntegerArray rms; + private final int masterSlot; + private final CueMarkerBank cueMarkers; + private final SceneBank scenes; + private final CursorTrack clipTrack; + private final Clip clip; + private final Map steps = new ConcurrentHashMap<>(); + private final Clip arrangerClip; + private final Map arrangerSteps = new ConcurrentHashMap<>(); + private final long timeoutMs; + + public BitwigSessionFacade(ControllerHost host, Logger logger, Transport transport, TrackBank trackBank) { + this(host, logger, transport, trackBank, 2000); + } + + BitwigSessionFacade(ControllerHost host, Logger logger, Transport transport, TrackBank trackBank, long timeoutMs) { + this.host = host; + this.logger = logger; + this.transport = transport; + this.trackBank = trackBank; + this.timeoutMs = timeoutMs; + + int tracks = trackBank.getSizeOfBank(); + this.masterSlot = tracks; + this.peak = new AtomicIntegerArray(tracks + 1); + this.rms = new AtomicIntegerArray(tracks + 1); + for (int t = 0; t < tracks; t++) { + observeMeter(trackBank.getItemAt(t), t); + trackBank.getItemAt(t).playingNotes().markInterested(); + } + this.master = host.createMasterTrack(0); + master.name().markInterested(); + observeMeter(master, masterSlot); + + Arranger arranger = host.createArranger(); + this.cueMarkers = arranger.createCueMarkerBank(Constants.CUE_MARKERS); + for (int i = 0; i < cueMarkers.getSizeOfBank(); i++) { + CueMarker m = cueMarkers.getItemAt(i); + m.exists().markInterested(); + m.getName().markInterested(); + m.position().markInterested(); + } + + this.scenes = host.createSceneBank(Constants.SCENES); + for (int i = 0; i < scenes.getSizeOfBank(); i++) { + scenes.getItemAt(i).exists().markInterested(); + scenes.getItemAt(i).name().markInterested(); + } + + transport.arrangerLoopStart().markInterested(); + transport.arrangerLoopDuration().markInterested(); + transport.timeSignature().markInterested(); + transport.timeSignature().numerator().markInterested(); + transport.timeSignature().denominator().markInterested(); + + this.clipTrack = host.createCursorTrack("WIGAI_CLIP_TRACK", "WigAI clip track", 0, 0, false); + clipTrack.name().markInterested(); + this.clip = clipTrack.createLauncherCursorClip(Constants.CLIP_GRID_WIDTH, Constants.CLIP_GRID_HEIGHT); + observeClip(clip, steps); + clip.launchQuantization().markInterested(); + clip.launchMode().markInterested(); + // Follows the clip selected in the arranger; the API cannot select an arranger clip itself. + this.arrangerClip = host.createArrangerCursorClip(Constants.CLIP_GRID_WIDTH, Constants.CLIP_GRID_HEIGHT); + observeClip(arrangerClip, arrangerSteps); + arrangerClip.getTrack().name().markInterested(); + + for (int i = 0; i < scenes.getSizeOfBank(); i++) { + scenes.getItemAt(i).color().markInterested(); + scenes.getItemAt(i).clipCount().markInterested(); + } + } + + private static void observeClip(Clip c, Map into) { + c.exists().markInterested(); + c.getLoopStart().markInterested(); + c.getLoopLength().markInterested(); + c.getPlayStart().markInterested(); + c.getPlayStop().markInterested(); + c.isLoopEnabled().markInterested(); + c.getShuffle().markInterested(); + c.getAccent().markInterested(); + c.getAccent().displayedValue().markInterested(); + c.color().markInterested(); + c.setStepSize(Constants.CLIP_STEP_BEATS); + c.scrollToKey(0); + c.scrollToStep(0); + c.addNoteStepObserver(step -> { + long key = stepKey(step.channel(), step.x(), step.y()); + if (step.state() == NoteStep.State.NoteOn) { + into.put(key, step); + } else { + into.remove(key); + } + }); + } + + /** A clip being worked on: the launcher cursor clip (after focusClip) or the arranger's selected clip. */ + private record ClipView(Clip clip, Map steps, Map where) {} + + private ClipView view(Integer trackIndex, Integer slotIndex, boolean arranger, String operation) throws BitwigApiException { + if (arranger) { + if (!waitFor(() -> arrangerClip.exists().get())) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, operation, + "No arranger clip is selected. Click a clip in Bitwig's arranger first."); + } + sleep(timeoutMs > 0 ? Constants.CLIP_LOAD_MS : 0); + Map where = new LinkedHashMap<>(); + where.put("arranger", true); + where.put("track", arrangerClip.getTrack().name().get()); + return new ClipView(arrangerClip, arrangerSteps, where); + } + if (trackIndex == null || slotIndex == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, + "Give a track and slot_index, or arranger: true"); + } + focusClip(trackIndex, slotIndex, operation); + Map where = new LinkedHashMap<>(); + where.put("track_index", trackIndex); + where.put("slot_index", slotIndex); + return new ClipView(clip, steps, where); + } + + private void observeMeter(Channel channel, int slot) { + channel.addVuMeterObserver(Constants.METER_RANGE, -1, true, v -> peak.set(slot, v)); + channel.addVuMeterObserver(Constants.METER_RANGE, -1, false, v -> rms.set(slot, v)); + } + + private static long stepKey(int channel, int x, int y) { + return ((long) channel << 40) | ((long) x << 8) | y; + } + + private boolean waitFor(BooleanSupplier condition) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + sleep(Constants.POLL_INTERVAL_MS); + } + return condition.getAsBoolean(); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // ======================================== + // Audio engine + // ======================================== + + /** + * Bitwig runs the audio engine for one open project at a time; a project without it cannot play, + * meter or audition. Activating it here takes it from whichever project had it. + */ + public Map setEngineActive(Application application, boolean active) { + if (active) { + application.activateEngine(); + } else { + application.deactivateEngine(); + } + waitFor(() -> application.hasActiveEngine().get() == active); + return Map.of("engine_active", application.hasActiveEngine().get()); + } + + // ======================================== + // Levels + // ======================================== + + /** + * Current meter readings. Values are Bitwig's meter scale normalized to 0-1 (not dB); they are only + * meaningful while the transport is playing or audio is otherwise flowing. + */ + public Map getLevels() { + List> tracks = new ArrayList<>(); + for (int t = 0; t < trackBank.getSizeOfBank(); t++) { + Track track = trackBank.getItemAt(t); + if (!track.exists().get() || "Master".equalsIgnoreCase(track.trackType().get())) { + continue; + } + tracks.add(level(t, track.name().get(), t)); + } + Map result = new LinkedHashMap<>(); + result.put("playing", transport.isPlaying().get()); + result.put("scale", "0-1 of Bitwig's meter range (not dB)"); + result.put("master", level(-1, master.name().get(), masterSlot)); + result.put("tracks", tracks); + return result; + } + + private Map level(int index, String name, int slot) { + Map m = new LinkedHashMap<>(); + if (index >= 0) { + m.put("track_index", index); + } + m.put("name", name); + m.put("peak", Math.round(peak.get(slot) * 1000.0 / Constants.METER_RANGE) / 1000.0); + m.put("rms", Math.round(rms.get(slot) * 1000.0 / Constants.METER_RANGE) / 1000.0); + return m; + } + + // ======================================== + // Cue markers, loop, time signature + // ======================================== + + public List> listCueMarkers() { + List> out = new ArrayList<>(); + for (int i = 0; i < cueMarkers.getSizeOfBank(); i++) { + CueMarker m = cueMarkers.getItemAt(i); + if (!m.exists().get()) { + continue; + } + Map e = new LinkedHashMap<>(); + e.put("index", i); + e.put("name", m.getName().get()); + e.put("position_beats", m.position().get()); + out.add(e); + } + return out; + } + + public Map goToCueMarker(Integer index, String name, boolean quantized) throws BitwigApiException { + for (int i = 0; i < cueMarkers.getSizeOfBank(); i++) { + CueMarker m = cueMarkers.getItemAt(i); + if (m.exists().get() && ((index != null && index == i) || (name != null && name.equalsIgnoreCase(m.getName().get())))) { + m.launch(quantized); + return Map.of("cue_marker", m.getName().get(), "position_beats", m.position().get()); + } + } + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, "goToCueMarker", "No such cue marker"); + } + + public Map setArrangement(Double loopStartBeats, Double loopLengthBeats, String timeSignature) { + if (loopStartBeats != null) { + transport.arrangerLoopStart().set(loopStartBeats); + } + if (loopLengthBeats != null) { + transport.arrangerLoopDuration().set(loopLengthBeats); + } + if (timeSignature != null) { + transport.timeSignature().set(timeSignature); + } + sleep(timeoutMs > 0 ? 120 : 0); + Map m = new LinkedHashMap<>(); + m.put("loop_start_beats", transport.arrangerLoopStart().get()); + m.put("loop_length_beats", transport.arrangerLoopDuration().get()); + m.put("time_signature", transport.timeSignature().get()); + return m; + } + + // ======================================== + // Scenes + // ======================================== + + public Map renameScene(int sceneIndex, String name) throws BitwigApiException { + if (sceneIndex < 0 || sceneIndex >= scenes.getSizeOfBank() || !scenes.getItemAt(sceneIndex).exists().get()) { + throw new BitwigApiException(ErrorCode.SCENE_NOT_FOUND, "renameScene", "No scene at index " + sceneIndex); + } + Scene scene = scenes.getItemAt(sceneIndex); + scene.name().set(name); + waitFor(() -> name.equals(scene.name().get())); + return Map.of("scene_index", sceneIndex, "name", scene.name().get()); + } + + private Scene scene(int index, String operation) throws BitwigApiException { + if (index < 0 || index >= scenes.getSizeOfBank() || !scenes.getItemAt(index).exists().get()) { + throw new BitwigApiException(ErrorCode.SCENE_NOT_FOUND, operation, "No scene at index " + index); + } + return scenes.getItemAt(index); + } + + private int sceneCount() { + int n = 0; + for (int i = 0; i < scenes.getSizeOfBank(); i++) { + if (scenes.getItemAt(i).exists().get()) { + n = i + 1; + } + } + return n; + } + + public List> sceneSummaries() { + List> out = new ArrayList<>(); + for (int i = 0; i < sceneCount(); i++) { + Scene sc = scenes.getItemAt(i); + Map m = new LinkedHashMap<>(); + m.put("scene_index", i); + m.put("name", sc.name().get()); + m.put("clip_count", sc.clipCount().get()); + out.add(m); + } + return out; + } + + /** Renames and/or colors a scene. */ + public Map setScene(int sceneIndex, String name, String color) throws BitwigApiException { + Scene sc = scene(sceneIndex, "setScene"); + if (name != null) { + sc.name().set(name); + waitFor(() -> name.equals(sc.name().get())); + } + if (color != null) { + int[] rgb = BitwigEditFacade.parseColor(color, "setScene"); + sc.color().set(rgb[0] / 255f, rgb[1] / 255f, rgb[2] / 255f); + sleep(timeoutMs > 0 ? 100 : 0); + } + return Map.of("scene_index", sceneIndex, "name", sc.name().get()); + } + + /** Deletes or duplicates a scene with all its clips. */ + public Map deleteOrDuplicateScene(int sceneIndex, boolean delete) throws BitwigApiException { + final String operation = delete ? "deleteScene" : "duplicateScene"; + Scene sc = scene(sceneIndex, operation); + int before = sceneCount(); + if (delete) { + sc.deleteObject(); + waitFor(() -> sceneCount() < before); + } else { + sc.duplicateObject(); + waitFor(() -> sceneCount() > before); + } + sleep(timeoutMs > 0 ? 150 : 0); + return Map.of("scene_count_before", before, "scenes", sceneSummaries()); + } + + /** Copies or moves all clips of a scene onto another scene's row. */ + public Map copyOrMoveScene(int fromIndex, int toIndex, boolean move) throws BitwigApiException { + final String operation = move ? "moveScene" : "copyScene"; + Scene from = scene(fromIndex, operation); + Scene to = scene(toIndex, operation); + if (move) { + to.replaceInsertionPoint().moveSlotsOrScenes(from); + } else { + to.replaceInsertionPoint().copySlotsOrScenes(from); + } + int clips = from.clipCount().get(); + waitFor(() -> to.clipCount().get() >= clips); + sleep(timeoutMs > 0 ? 150 : 0); + return Map.of("action", move ? "moved" : "copied", "scenes", sceneSummaries()); + } + + // ======================================== + // Tracks: stop, return to arrangement, audition notes + // ======================================== + + public Map stopTrack(int trackIndex, boolean returnToArrangement) { + Track track = trackBank.getItemAt(trackIndex); + if (returnToArrangement) { + track.returnToArrangement(); + } else { + track.stop(); + } + return Map.of("track", track.name().get(), "action", returnToArrangement ? "return_to_arrangement" : "stop"); + } + + /** A note to audition: MIDI pitch, velocity, start offset and length in milliseconds. */ + public record AuditionNote(int pitch, int velocity, long startMs, long durationMs) {} + + /** + * Plays notes into a track's instrument right now, without a clip. Each note is started and stopped + * on Bitwig's control thread by scheduled tasks, so the call returns immediately. + */ + public Map playNotes(int trackIndex, List notes) { + Track track = trackBank.getItemAt(trackIndex); + long end = 0; + for (AuditionNote n : notes) { + int pitch = Math.max(0, Math.min(127, n.pitch())); + int velocity = Math.max(1, Math.min(127, n.velocity())); + host.scheduleTask(() -> track.startNote(pitch, velocity), Math.max(0, n.startMs())); + host.scheduleTask(() -> track.stopNote(pitch, 0), Math.max(0, n.startMs()) + Math.max(1, n.durationMs())); + end = Math.max(end, n.startMs() + n.durationMs()); + } + return Map.of("track", track.name().get(), "notes", notes.size(), "total_ms", end); + } + + /** The notes sounding right now on every track that has any (from clips, input or play_notes). */ + public Map playingNotes() { + List> out = new ArrayList<>(); + for (int t = 0; t < trackBank.getSizeOfBank(); t++) { + Track track = trackBank.getItemAt(t); + if (!track.exists().get()) { + continue; + } + PlayingNote[] notes = track.playingNotes().get(); + if (notes == null || notes.length == 0) { + continue; + } + List> list = new ArrayList<>(); + for (PlayingNote n : notes) { + list.add(Map.of("pitch", n.pitch(), "velocity", n.velocity())); + } + out.add(Map.of("track_index", t, "track", track.name().get(), "notes", list)); + } + return Map.of("playing", transport.isPlaying().get(), "tracks", out); + } + + /** + * Sends a MIDI message into a track's instrument: cc (number, value 0-127), pitch_bend (value -8192..8191), + * program (number), aftertouch (value), or raw (status, number = data1, value = data2). channel is 0-15. + */ + public Map sendMidi(int trackIndex, String type, int channel, Integer number, Integer value, + Integer status) throws BitwigApiException { + final String operation = "sendMidi"; + if (channel < 0 || channel > 15) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "channel must be 0-15"); + } + int[] msg = midiMessage(type, channel, number, value, status, operation); + Track track = trackBank.getItemAt(trackIndex); + track.sendMidi(msg[0], msg[1], msg[2]); + return Map.of("track", track.name().get(), "status", msg[0], "data1", msg[1], "data2", msg[2]); + } + + static int[] midiMessage(String type, int channel, Integer number, Integer value, Integer status, String operation) + throws BitwigApiException { + String t = type == null ? "" : type.toLowerCase(Locale.ROOT); + return switch (t) { + case "cc" -> new int[]{0xB0 | channel, data(number, "number", operation), data(value, "value", operation)}; + case "program" -> new int[]{0xC0 | channel, data(number, "number", operation), 0}; + case "aftertouch" -> new int[]{0xD0 | channel, data(value, "value", operation), 0}; + case "pitch_bend" -> { + if (value == null || value < -8192 || value > 8191) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "pitch_bend value must be -8192..8191"); + } + int v = value + 8192; + yield new int[]{0xE0 | channel, v & 0x7F, (v >> 7) & 0x7F}; + } + case "raw" -> { + if (status == null || status < 0x80 || status > 0xEF) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "raw needs a channel-voice status 0x80-0xEF"); + } + yield new int[]{status, data(number, "number", operation), value == null ? 0 : data(value, "value", operation)}; + } + default -> throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "type must be cc, pitch_bend, program, aftertouch or raw"); + }; + } + + private static int data(Integer v, String name, String operation) throws BitwigApiException { + if (v == null || v < 0 || v > 127) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, name + " must be 0-127"); + } + return v; + } + + // ======================================== + // Launcher clips: read notes and properties + // ======================================== + + private ClipLauncherSlot focusClip(int trackIndex, int slotIndex, String operation) throws BitwigApiException { + Track track = trackBank.getItemAt(trackIndex); + ClipLauncherSlotBank slots = track.clipLauncherSlotBank(); + if (slotIndex < 0 || slotIndex >= slots.getSizeOfBank()) { + throw new BitwigApiException(ErrorCode.INVALID_RANGE, operation, "slot_index must be 0.." + (slots.getSizeOfBank() - 1)); + } + ClipLauncherSlot slot = slots.getItemAt(slotIndex); + if (!slot.hasContent().get()) { + throw new BitwigApiException(ErrorCode.CLIP_NOT_FOUND, operation, "Slot " + slotIndex + " is empty"); + } + // The note map is kept current by the step observer, which reports every cell that changes as + // the cursor clip moves; clearing it here would drop notes delivered by selectChannel already. + clipTrack.selectChannel(track); + waitFor(() -> track.name().get().equals(clipTrack.name().get())); + slot.select(); + if (!waitFor(() -> clip.exists().get())) { + throw new BitwigApiException(ErrorCode.BITWIG_TIMEOUT, operation, "Could not focus the clip"); + } + sleep(timeoutMs > 0 ? Constants.CLIP_LOAD_MS : 0); + return slot; + } + + /** Reads the notes of a launcher clip (beats from the clip start, on a 1/16-beat grid). */ + public Map readNotes(int trackIndex, int slotIndex) throws BitwigApiException { + return readNotes(trackIndex, slotIndex, false); + } + + public Map readNotes(Integer trackIndex, Integer slotIndex, boolean arranger) throws BitwigApiException { + ClipView v = view(trackIndex, slotIndex, arranger, "readNotes"); + List found = new ArrayList<>(v.steps().values()); + found.sort(Comparator.comparingInt(NoteStep::x).thenComparingInt(NoteStep::y)); + List> notes = new ArrayList<>(); + for (NoteStep s : found) { + notes.add(describeNote(s)); + } + Map result = new LinkedHashMap<>(v.where()); + result.putAll(clipProperties(v.clip())); + result.put("note_count", notes.size()); + result.put("notes", notes); + return result; + } + + /** A note's position and velocity, plus every expression or condition that is not at its default. */ + static Map describeNote(NoteStep s) { + Map n = new LinkedHashMap<>(); + n.put("start", s.x() * Constants.CLIP_STEP_BEATS); + n.put("pitch", s.y()); + n.put("velocity", (int) Math.round(s.velocity() * 127)); + n.put("duration", s.duration()); + n.put("channel", s.channel()); + putIf(n, "release_velocity", s.releaseVelocity(), 0.5); + putIf(n, "velocity_spread", s.velocitySpread(), 0); + putIf(n, "gain", s.gain(), 0); + putIf(n, "pan", s.pan(), 0); + putIf(n, "timbre", s.timbre(), 0); + putIf(n, "pressure", s.pressure(), 0); + putIf(n, "transpose", s.transpose(), 0); + if (s.isChanceEnabled() && s.chance() < 1.0 - 1e-6) { + n.put("chance", s.chance()); + } + if (s.isOccurrenceEnabled()) { + n.put("occurrence", s.occurrence().name().toLowerCase(Locale.ROOT)); + } + if (s.isRecurrenceEnabled()) { + n.put("recurrence_length", s.recurrenceLength()); + n.put("recurrence_mask", s.recurrenceMask()); + } + if (s.isRepeatEnabled()) { + n.put("repeats", s.repeatCount()); + putIf(n, "repeat_curve", s.repeatCurve(), 0); + putIf(n, "repeat_velocity_end", s.repeatVelocityEnd(), 0); + putIf(n, "repeat_velocity_curve", s.repeatVelocityCurve(), 0); + } + if (s.isMuted()) { + n.put("muted", true); + } + return n; + } + + private static void putIf(Map m, String key, double value, double dflt) { + if (Math.abs(value - dflt) > 1e-6) { + m.put(key, value); + } + } + + public Map setClip(int trackIndex, int slotIndex, String name, Double loopStartBeats, + Double loopLengthBeats) throws BitwigApiException { + return setClip(trackIndex, slotIndex, false, new ClipSettings(name, loopStartBeats, loopLengthBeats, null, + null, null, null, null, null)); + } + + /** Clip settings; null fields are left alone. accent is 0-1. */ + public record ClipSettings(String name, Double loopStartBeats, Double loopLengthBeats, Boolean loopEnabled, + Double playStartBeats, Double playStopBeats, Boolean shuffle, Double accent, + String color) {} + + public Map setClip(Integer trackIndex, Integer slotIndex, boolean arranger, ClipSettings c) + throws BitwigApiException { + final String operation = "setClip"; + ClipView v = view(trackIndex, slotIndex, arranger, operation); + Clip target = v.clip(); + if (c.name() != null) { + target.setName(c.name()); + } + if (c.loopStartBeats() != null) { + target.getLoopStart().set(c.loopStartBeats()); + } + if (c.loopLengthBeats() != null) { + target.getLoopLength().set(c.loopLengthBeats()); + } + if (c.loopEnabled() != null) { + target.isLoopEnabled().set(c.loopEnabled()); + } + if (c.playStartBeats() != null) { + target.getPlayStart().set(c.playStartBeats()); + } + if (c.playStopBeats() != null) { + target.getPlayStop().set(c.playStopBeats()); + } + if (c.shuffle() != null) { + target.getShuffle().set(c.shuffle()); + } + if (c.accent() != null) { + target.getAccent().setImmediately(c.accent()); + } + if (c.color() != null) { + int[] rgb = BitwigEditFacade.parseColor(c.color(), operation); + target.color().set(rgb[0] / 255f, rgb[1] / 255f, rgb[2] / 255f); + } + sleep(timeoutMs > 0 ? 150 : 0); + Map result = new LinkedHashMap<>(v.where()); + result.putAll(clipProperties(target)); + return result; + } + + /** Which notes to change: optional pitch, and a start range in beats from the clip start. */ + public record NoteFilter(Integer pitch, Double fromBeats, Double toBeats) { + boolean matches(NoteStep s) { + double start = s.x() * Constants.CLIP_STEP_BEATS; + return (pitch == null || s.y() == pitch) && (fromBeats == null || start >= fromBeats - 1e-9) + && (toBeats == null || start < toBeats - 1e-9); + } + } + + /** Per-note changes; null fields are left alone. Ranges follow Bitwig's NoteStep API. */ + public record NoteChanges(Double velocity, Double duration, Double gain, Double pan, Double timbre, + Double pressure, Double transpose, Double chance, Boolean muted, Integer repeats, + Double releaseVelocity, Double velocitySpread, String occurrence, + Integer recurrenceLength, Integer recurrenceMask, Double repeatCurve, + Double repeatVelocityEnd, Double repeatVelocityCurve) { + public NoteChanges(Double velocity, Double duration, Double gain, Double pan, Double timbre, Double pressure, + Double transpose, Double chance, Boolean muted, Integer repeats) { + this(velocity, duration, gain, pan, timbre, pressure, transpose, chance, muted, repeats, + null, null, null, null, null, null, null, null); + } + } + + /** Parses an occurrence condition name; "off" (or "always") disables the condition. */ + static NoteOccurrence occurrence(String name, String operation) throws BitwigApiException { + String key = name.trim().toUpperCase(Locale.ROOT).replace('-', '_').replace(' ', '_'); + for (NoteOccurrence o : NoteOccurrence.values()) { + if (o.name().equals(key)) { + return o; + } + } + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "occurrence must be off, always, first, not_first, prev, not_prev, prev_channel or not_prev_channel"); + } + + public Map editNotes(int trackIndex, int slotIndex, NoteFilter filter, NoteChanges c) + throws BitwigApiException { + return editNotes(trackIndex, slotIndex, false, filter, c); + } + + public Map editNotes(Integer trackIndex, Integer slotIndex, boolean arranger, NoteFilter filter, + NoteChanges c) throws BitwigApiException { + final String operation = "editNotes"; + boolean occurrenceOff = c.occurrence() != null + && List.of("off", "none", "always").contains(c.occurrence().trim().toLowerCase(Locale.ROOT)); + NoteOccurrence occ = c.occurrence() == null || occurrenceOff ? null : occurrence(c.occurrence(), operation); + if ((c.recurrenceLength() == null) != (c.recurrenceMask() == null) && !Integer.valueOf(0).equals(c.recurrenceLength())) { + throw new BitwigApiException(ErrorCode.INVALID_PARAMETER, operation, + "recurrence_length and recurrence_mask go together (length 0 turns recurrence off)"); + } + ClipView v = view(trackIndex, slotIndex, arranger, operation); + int changed = 0; + for (NoteStep s : new ArrayList<>(v.steps().values())) { + if (!filter.matches(s)) { + continue; + } + if (c.velocity() != null) { + s.setVelocity(c.velocity()); + } + if (c.duration() != null) { + s.setDuration(c.duration()); + } + if (c.gain() != null) { + s.setGain(c.gain()); + } + if (c.pan() != null) { + s.setPan(c.pan()); + } + if (c.timbre() != null) { + s.setTimbre(c.timbre()); + } + if (c.pressure() != null) { + s.setPressure(c.pressure()); + } + if (c.transpose() != null) { + s.setTranspose(c.transpose()); + } + if (c.chance() != null) { + s.setIsChanceEnabled(c.chance() < 1.0); + s.setChance(c.chance()); + } + if (c.muted() != null) { + s.setIsMuted(c.muted()); + } + if (c.repeats() != null) { + s.setIsRepeatEnabled(c.repeats() > 0); + s.setRepeatCount(Math.max(0, c.repeats())); + } + if (c.releaseVelocity() != null) { + s.setReleaseVelocity(c.releaseVelocity()); + } + if (c.velocitySpread() != null) { + s.setVelocitySpread(c.velocitySpread()); + } + if (occurrenceOff) { + s.setIsOccurrenceEnabled(false); + } else if (occ != null) { + s.setOccurrence(occ); + s.setIsOccurrenceEnabled(true); + } + if (c.recurrenceLength() != null) { + if (c.recurrenceLength() <= 0) { + s.setIsRecurrenceEnabled(false); + } else { + s.setRecurrence(c.recurrenceLength(), c.recurrenceMask()); + s.setIsRecurrenceEnabled(true); + } + } + if (c.repeatCurve() != null) { + s.setRepeatCurve(c.repeatCurve()); + } + if (c.repeatVelocityEnd() != null) { + s.setRepeatVelocityEnd(c.repeatVelocityEnd()); + } + if (c.repeatVelocityCurve() != null) { + s.setRepeatVelocityCurve(c.repeatVelocityCurve()); + } + changed++; + } + sleep(timeoutMs > 0 ? 200 : 0); + Map result = new LinkedHashMap<>(v.where()); + result.put("notes_changed", changed); + return result; + } + + /** Writes notes into the arranger's selected clip (launcher clips use write_notes on the edit facade). */ + public Map writeArrangerNotes(List notes, boolean clearFirst) + throws BitwigApiException { + ClipView v = view(null, null, true, "writeNotes"); + if (clearFirst) { + v.clip().clearSteps(); + } + int written = 0; + for (BitwigEditFacade.Note n : notes) { + int step = (int) Math.round(n.startBeats() / Constants.CLIP_STEP_BEATS); + if (step < 0 || step >= Constants.CLIP_GRID_WIDTH || n.pitch() < 0 || n.pitch() > 127) { + continue; + } + v.clip().setStep(step, n.pitch(), Math.max(1, Math.min(127, n.velocity())), n.durationBeats()); + written++; + } + Map result = new LinkedHashMap<>(v.where()); + result.put("notes_written", written); + return result; + } + + /** Whole-clip operations: transpose by semitones, quantize (0-1 strength), duplicate content, launch settings. */ + public Map transformClip(int trackIndex, int slotIndex, Integer transpose, Double quantize, + Boolean duplicateContent, String launchQuantization, String launchMode) + throws BitwigApiException { + return transformClip(trackIndex, slotIndex, false, transpose, quantize, duplicateContent, launchQuantization, + launchMode); + } + + public Map transformClip(Integer trackIndex, Integer slotIndex, boolean arranger, Integer transpose, + Double quantize, Boolean duplicateContent, String launchQuantization, + String launchMode) throws BitwigApiException { + ClipView v = view(trackIndex, slotIndex, arranger, "transformClip"); + Clip clip = v.clip(); + if (transpose != null) { + clip.transpose(transpose); + } + if (quantize != null) { + clip.quantize(quantize); + } + if (Boolean.TRUE.equals(duplicateContent)) { + clip.duplicateContent(); + } + if (launchQuantization != null) { + clip.launchQuantization().set(launchQuantization); + } + if (launchMode != null) { + clip.launchMode().set(launchMode); + } + sleep(timeoutMs > 0 ? 300 : 0); + Map result = new LinkedHashMap<>(v.where()); + result.putAll(clipProperties(clip)); + if (!arranger) { + result.put("launch_quantization", clip.launchQuantization().get()); + result.put("launch_mode", clip.launchMode().get()); + } + return result; + } + + private static Map clipProperties(Clip c) { + Map m = new LinkedHashMap<>(); + m.put("loop_enabled", c.isLoopEnabled().get()); + m.put("loop_start_beats", c.getLoopStart().get()); + m.put("loop_length_beats", c.getLoopLength().get()); + m.put("play_start_beats", c.getPlayStart().get()); + m.put("play_stop_beats", c.getPlayStop().get()); + m.put("shuffle", c.getShuffle().get()); + m.put("accent", c.getAccent().get()); + m.put("accent_display", c.getAccent().displayedValue().get()); + return m; + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java b/src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java new file mode 100644 index 00000000..63004f94 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/DeviceCatalog.java @@ -0,0 +1,681 @@ +package io.github.fabb.wigai.bitwig; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Knowledge about every device and plugin installed: what it is (from disk), what it is for (the bundled + * device guide), and what its controls do (measured in Bitwig, on demand, then cached). + * + * The catalog file is shared with scripts/device_catalog.py, which writes the disk inventory (Bitwig + * devices, CLAP descriptors, LV2 plugins) and can pre-fill scans in bulk. This class adds on-demand scans + * of single devices and keeps a scan history per entry. + */ +public class DeviceCatalog { + + private static final double[] SWEEP = {0.0, 0.25, 0.5, 0.75, 1.0}; + private static final int OPTION_STEPS = 16; + private static final int HISTORY_LIMIT = 20; + private static final String SCRATCH_TRACK = "wigai scan (temporary)"; + private static final Pattern NUMBER = Pattern.compile("^\\s*([+\\-−]?\\d+(?:\\.\\d+)?)\\s*(.*)$"); + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final Path file; + private final BitwigEditFacade edit; + private final Logger logger; + private final String bitwigVersion; + private final ObjectMapper json = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + private final Map> guide; + private final Path pluginCacheDir; + + /** Catalog sections: Bitwig devices, plugins by format (from Bitwig's plugin cache), LV2 via Carla, other. */ + private static final List SECTIONS = List.of("bitwig", "clap", "vst3", "vst2", "lv2", "other"); + + public DeviceCatalog(Path file, BitwigEditFacade edit, Logger logger, String bitwigVersion) { + this(file, edit, logger, bitwigVersion, PluginCache.defaultDir()); + } + + public DeviceCatalog(Path file, BitwigEditFacade edit, Logger logger, String bitwigVersion, Path pluginCacheDir) { + this.pluginCacheDir = pluginCacheDir; + this.file = file; + this.edit = edit; + this.logger = logger; + this.bitwigVersion = bitwigVersion; + this.guide = loadGuide(file.resolveSibling("device-guide.json")); + } + + /** ~/.local/share/wigai/device-catalog.json, or $WIGAI_CATALOG_DIR/device-catalog.json. */ + public static Path defaultFile() { + String dir = System.getenv("WIGAI_CATALOG_DIR"); + Path base = dir != null ? Path.of(dir) : Path.of(System.getProperty("user.home"), ".local", "share", "wigai"); + return base.resolve("device-catalog.json"); + } + + // ======================================== + // Storage + // ======================================== + + @SuppressWarnings("unchecked") + synchronized Map load() { + Map cat = new LinkedHashMap<>(); + if (Files.isRegularFile(file)) { + try { + cat = json.readValue(file.toFile(), new TypeReference>() {}); + } catch (Exception e) { + logger.error("DeviceCatalog: could not read " + file + ": " + e.getMessage()); + } + } + for (String section : SECTIONS) { + cat.computeIfAbsent(section, k -> new LinkedHashMap()); + } + for (PluginCache.Plugin p : PluginCache.read(pluginCacheDir)) { + Map e = (Map) ((Map) cat.get(p.kind())) + .computeIfAbsent(p.id(), k -> new LinkedHashMap<>()); + e.putIfAbsent("name", p.name()); + putIfPresent(e, "vendor", p.vendor()); + putIfPresent(e, "category", p.category()); + putIfPresent(e, "version", p.version()); + if (!p.features().isEmpty()) { + e.putIfAbsent("features", p.features()); + } + putIfPresent(e, "file", p.file()); + } + Map bitwig = (Map) cat.get("bitwig"); + for (Map d : edit.listBitwigDevices(null)) { + Map e = (Map) bitwig.computeIfAbsent((String) d.get("name"), k -> new LinkedHashMap<>()); + e.putIfAbsent("name", d.get("name")); + e.putIfAbsent("type", d.get("type")); + e.putIfAbsent("category", d.get("category")); + } + return cat; + } + + synchronized void save(Map cat) { + try { + Files.createDirectories(file.getParent()); + Path tmp = file.resolveSibling(file.getFileName() + ".tmp"); + json.writeValue(tmp.toFile(), cat); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + logger.error("DeviceCatalog: could not write " + file + ": " + e.getMessage()); + } + } + + private record Found(String section, String key, Map entry) {} + + private static void putIfPresent(Map m, String key, String value) { + if (value != null && !value.isEmpty()) { + m.putIfAbsent(key, value); + } + } + + @SuppressWarnings("unchecked") + private static Found find(Map cat, String query) { + String q = query.trim().toLowerCase(Locale.ROOT); + for (String section : SECTIONS) { + Map entries = (Map) cat.get(section); + for (Map.Entry e : entries.entrySet()) { + Map entry = (Map) e.getValue(); + if (e.getKey().equalsIgnoreCase(q) || q.equalsIgnoreCase(String.valueOf(entry.get("name")))) { + return new Found(section, e.getKey(), entry); + } + } + } + return null; + } + + @SuppressWarnings("unchecked") + /** Optional user-supplied notes per device, next to the catalog; nothing editorial ships with the extension. */ + private static Map> loadGuide(Path guideFile) { + if (!Files.isRegularFile(guideFile)) { + return Map.of(); + } + try (InputStream in = Files.newInputStream(guideFile)) { + Map raw = new ObjectMapper().readValue(in, new TypeReference>() {}); + Map> out = new LinkedHashMap<>(); + raw.forEach((k, v) -> { + if (!k.startsWith("_")) { + out.put(k.toLowerCase(Locale.ROOT), (Map) v); + } + }); + return out; + } catch (Exception e) { + return Map.of(); + } + } + + private Map guideFor(String section, String key, Map entry) { + Map g = guide.get(key.toLowerCase(Locale.ROOT)); + if (g == null && entry.get("name") != null) { + g = guide.get(String.valueOf(entry.get("name")).toLowerCase(Locale.ROOT)); + } + return g; + } + + // ======================================== + // Listing and searching + // ======================================== + + /** + * Everything installed, optionally filtered by words matched against name, vendor, type, category, + * description, feature tags and the device guide. Use it to pick candidates for a job. + */ + @SuppressWarnings("unchecked") + public Map listInstalled(String query, String kind) { + Map cat = load(); + List words = query == null ? List.of() : List.of(query.toLowerCase(Locale.ROOT).split("\\s+")); + List> out = new ArrayList<>(); + for (String section : SECTIONS) { + if (kind != null && !kind.equalsIgnoreCase(section)) { + continue; + } + for (Map.Entry e : ((Map) cat.get(section)).entrySet()) { + Map entry = (Map) e.getValue(); + Map g = guideFor(section, e.getKey(), entry); + String text = String.join(" ", e.getKey(), str(entry.get("name")), str(entry.get("vendor")), + str(entry.get("type")), str(entry.get("category")), str(entry.get("description")), + str(entry.get("features")), g == null ? "" : str(g.get("use_for")) + " " + str(g.get("tags"))) + .toLowerCase(Locale.ROOT); + int score = 0; + for (String w : words) { + if (text.contains(w)) { + score++; + } + } + if (!words.isEmpty() && score == 0) { + continue; + } + Map m = new LinkedHashMap<>(); + m.put("kind", section); + m.put("name", entry.getOrDefault("name", e.getKey())); + if (!"bitwig".equals(section)) { + m.put("id", e.getKey()); + m.put("vendor", entry.get("vendor")); + } + m.put("type", section.equals("bitwig") ? entry.get("type") + : entry.get("features") != null ? str(entry.get("features")) : entry.get("category")); + if (g != null) { + m.put("use_for", g.get("use_for")); + } else if (entry.get("description") != null && !str(entry.get("description")).isBlank()) { + m.put("description", truncate(str(entry.get("description")), 160)); + } + m.put("scanned", entry.get("scan") instanceof Map s && !s.containsKey("error")); + m.put("score", score); + out.add(m); + } + } + out.sort(Comparator.comparingInt((Map m) -> -(int) m.get("score")) + .thenComparing(m -> !"bitwig".equals(m.get("kind"))) + .thenComparing(m -> str(m.get("name")).toLowerCase(Locale.ROOT))); + Map result = new LinkedHashMap<>(); + result.put("count", out.size()); + result.put("devices", out.size() > 150 ? out.subList(0, 150) : out); + if (Stream.of("clap", "vst3", "vst2").allMatch(k -> ((Map) cat.get(k)).isEmpty())) { + result.put("note", "No plugins found in Bitwig's plugin cache (" + pluginCacheDir + + "). Bitwig fills it when it scans plugin folders (Settings > Locations)."); + } + return result; + } + + // ======================================== + // Describing (and scanning on demand) + // ======================================== + + /** + * Everything known about a device: what it is, what it is for, and its controls. Without a cached scan + * (or with rescan), the controls are measured now: on the given device instance when track/device are + * given (values are restored afterwards), otherwise on a temporary track that is deleted again. + */ + @SuppressWarnings("unchecked") + public Map describe(String name, Integer trackIndex, Integer deviceIndex, String chain, + Integer chainDeviceIndex, boolean scanIfMissing, boolean rescan) + throws BitwigApiException { + final String operation = "describeDevice"; + Map cat = load(); + String instanceName = null; + if (trackIndex != null && deviceIndex != null) { + instanceName = (String) edit.getDeviceParameters(trackIndex, deviceIndex, chain, chainDeviceIndex, 0).get("device"); + } + String query = name != null ? name : instanceName; + if (query == null) { + throw new BitwigApiException(ErrorCode.MISSING_REQUIRED_PARAMETER, operation, "Give name, or track and device_index"); + } + Found found = find(cat, query); + if (found == null) { + Map other = (Map) cat.get("other"); + Map entry = new LinkedHashMap<>(); + entry.put("name", query); + other.put(query, entry); + found = new Found("other", query, entry); + } + Map entry = found.entry(); + Object scan = entry.get("scan"); + boolean haveScan = scan instanceof Map s && !s.containsKey("error") && ((Map) s).containsKey("pages"); + String scanNote = null; + if ((rescan || !haveScan) && scanIfMissing && !"lv2".equals(found.section())) { + if (edit.isPlaying()) { + scanNote = "Not scanned: the transport is playing and scanning moves every control. Stop playback and ask again."; + } else { + Map result = trackIndex != null && deviceIndex != null + ? scanInstance(trackIndex, deviceIndex, chain, chainDeviceIndex) + : scanFresh(found); + recordScan(entry, result, trackIndex != null ? "on-demand (instance)" : "on-demand (temporary track)"); + save(cat); + } + } + Map out = new LinkedHashMap<>(); + out.put("kind", found.section()); + out.put("key", found.key()); + out.putAll(entry); + Map g = guideFor(found.section(), found.key(), entry); + if (g != null) { + out.put("user_notes", g); + } + if (scanNote != null) { + out.put("scan_note", scanNote); + } + if (out.get("scan_history") instanceof List h && h.size() > 5) { + out.put("scan_history", h.subList(h.size() - 5, h.size())); + } + return out; + } + + private Map scanFresh(Found found) throws BitwigApiException { + final String operation = "describeDevice"; + String bitwigName = "bitwig".equals(found.section()) ? str(found.entry().get("name")) : null; + String clapId = "clap".equals(found.section()) ? found.key() : null; + String vst3Id = "vst3".equals(found.section()) ? found.key() : null; + Integer vst2Id = "vst2".equals(found.section()) ? Integer.valueOf(found.key()) : null; + if (bitwigName == null && clapId == null && vst3Id == null && vst2Id == null) { + throw new BitwigApiException(ErrorCode.DEVICE_NOT_FOUND, operation, + "'" + found.key() + "' can only be scanned on an instance: insert it and pass track and device_index"); + } + int track = (int) edit.createTrack("instrument", null, SCRATCH_TRACK).get("track_index"); + try { + Map ins = edit.insertDevice(track, null, null, bitwigName, clapId, vst3Id, vst2Id, null, + "end", null); + if (!Boolean.TRUE.equals(ins.get("inserted"))) { + Map err = new LinkedHashMap<>(); + err.put("error", "Bitwig did not insert it"); + return err; + } + for (int i = 0; i < 8; i++) { // plugins publish their pages after loading + Map probe = edit.getDeviceParameters(track, 0, null, null, null); + if (((String[]) probe.get("page_names")).length > 0 || clapId == null) { + break; + } + sleep(500); + } + return scanInstance(track, 0, null, null); + } finally { + edit.deleteTrack(track); + } + } + + /** Sweeps every control of one device instance and restores the values it had. */ + @SuppressWarnings("unchecked") + Map scanInstance(int track, int device, String chain, Integer chainDevice) throws BitwigApiException { + Map base = edit.getDeviceParameters(track, device, chain, chainDevice, null); + Map scan = new LinkedHashMap<>(); + scan.put("page_names", base.get("page_names")); + if (chain == null) { + try { + List> chains = new ArrayList<>(); + for (Map c : (List>) edit.getDeviceChains(track, device).get("chains")) { + List names = new ArrayList<>(); + for (Map d : (List>) c.get("devices")) { + names.add(d.get("name")); + } + chains.add(Map.of("chain", c.get("chain"), "default_devices", names)); + } + scan.put("chains", chains); + } catch (BitwigApiException e) { + scan.put("chains", List.of()); + } + } + record Control(int page, int index, String name, double value, String display) {} + List controls = new ArrayList<>(); + for (Map pg : (List>) base.get("pages")) { + for (Map p : (List>) pg.get("parameters")) { + controls.add(new Control((int) pg.get("page"), (int) p.get("index"), str(p.get("name")), + ((Number) p.get("value")).doubleValue(), str(p.get("display")))); + } + } + if (controls.isEmpty()) { + scan.put("pages", List.of()); + scan.put("note", "Bitwig exposes no remote-control pages for this device, so its parameters cannot be " + + "reached through the API (plugins only get pages when they define them)."); + return scan; + } + Map> samples = new LinkedHashMap<>(); + try { + for (double x : SWEEP) { + sweep(track, device, chain, chainDevice, controls.stream().map(c -> new int[]{c.page(), c.index()}).toList(), x, samples); + } + List optionControls = new ArrayList<>(); + for (Control c : controls) { + if (!isContinuous(samples.get(c.page() + ":" + c.index()))) { + optionControls.add(new int[]{c.page(), c.index()}); + samples.get(c.page() + ":" + c.index()).clear(); + } + } + if (!optionControls.isEmpty()) { + for (int i = 0; i <= OPTION_STEPS; i++) { + sweep(track, device, chain, chainDevice, optionControls, (double) i / OPTION_STEPS, samples); + } + } + } finally { + List restore = new ArrayList<>(); + for (Control c : controls) { + restore.add(new BitwigEditFacade.ParamAssignment(c.page(), c.index(), null, c.value())); + } + edit.setDeviceParameters(track, device, chain, chainDevice, restore, false); + } + Map>> byPage = new LinkedHashMap<>(); + Set seen = new LinkedHashSet<>(); + for (Control c : controls) { + List s = samples.get(c.page() + ":" + c.index()); + Map m = new LinkedHashMap<>(); + m.put("index", c.index()); + m.put("name", c.name()); + m.put("default", c.display()); + m.put("default_value", Math.round(c.value() * 10000) / 10000.0); + if (isContinuous(s)) { + m.put("kind", "continuous"); + m.put("min", s.get(0)); + m.put("max", s.get(s.size() - 1)); + m.put("curve", s); + } else { + m.put("kind", "options"); + m.put("options", new ArrayList<>(new LinkedHashSet<>(s))); + } + m.put("also_on_other_page", !seen.add(c.name())); + byPage.computeIfAbsent(c.page(), k -> new ArrayList<>()).add(m); + } + String[] pageNames = (String[]) base.get("page_names"); + List> pages = new ArrayList<>(); + byPage.forEach((pg, list) -> pages.add(Map.of("page", pg, "page_name", pg < pageNames.length ? pageNames[pg] : "", + "controls", list))); + scan.put("pages", pages); + return scan; + } + + @SuppressWarnings("unchecked") + private void sweep(int track, int device, String chain, Integer chainDevice, List targets, double x, + Map> samples) throws BitwigApiException { + List set = new ArrayList<>(); + Set pages = new LinkedHashSet<>(); + for (int[] t : targets) { + set.add(new BitwigEditFacade.ParamAssignment(t[0], t[1], null, x)); + pages.add(t[0]); + } + edit.setDeviceParameters(track, device, chain, chainDevice, set, false); + Set wanted = new LinkedHashSet<>(); + for (int[] t : targets) { + wanted.add(t[0] + ":" + t[1]); + } + Map now = edit.getDeviceParameters(track, device, chain, chainDevice, pages.size() == 1 ? pages.iterator().next() : null); + for (Map pg : (List>) now.get("pages")) { + for (Map p : (List>) pg.get("parameters")) { + String k = pg.get("page") + ":" + p.get("index"); + if (wanted.contains(k)) { + samples.computeIfAbsent(k, kk -> new ArrayList<>()).add(str(p.get("display"))); + } + } + } + } + + /** A control is continuous when every sampled display is a number ("-Inf dB" counts as one). */ + static boolean isContinuous(List displays) { + if (displays == null || displays.isEmpty()) { + return false; + } + for (String d : displays) { + String s = d.replace('−', '-').trim(); + if (s.toLowerCase(Locale.ROOT).startsWith("-inf") || s.toLowerCase(Locale.ROOT).startsWith("inf")) { + continue; + } + Matcher m = NUMBER.matcher(s); + if (!m.matches()) { + return false; + } + } + return true; + } + + // ======================================== + // Bitwig's own descriptions (fetched from its online user guide) + // ======================================== + + private static final String USERGUIDE = "https://www.bitwig.com/userguide/latest/"; + private static final Set GUIDE_STOP = Set.of("modulator", "modulators", "grid_modules", "the_grid", + "legacy_devices", "credits"); + + /** + * Copies each Bitwig device's description from Bitwig's online user guide into the local catalog + * (field bitwig_docs). The text is Bitwig's; it is fetched onto the user's machine, not redistributed. + */ + @SuppressWarnings("unchecked") + public Map fetchDocs() throws BitwigApiException { + java.net.http.HttpClient http = java.net.http.HttpClient.newBuilder() + .followRedirects(java.net.http.HttpClient.Redirect.NORMAL) + .connectTimeout(java.time.Duration.ofSeconds(20)).build(); + Map cat = load(); + Map bitwig = (Map) cat.get("bitwig"); + List pages = new ArrayList<>(); + int found = 0; + try { + String index = get(http, USERGUIDE + "device_descriptions"); + Matcher links = Pattern.compile("href=\"/userguide/latest/([^\"#]+)\"").matcher(index); + boolean inChapter = false; + while (links.find()) { + String link = links.group(1); + if (link.equals("device_descriptions")) { + inChapter = true; + } else if (inChapter) { + if (GUIDE_STOP.contains(link)) { + break; + } + if (!pages.contains(link)) { + pages.add(link); + } + } + } + for (String page : pages) { + String html = get(http, USERGUIDE + page); + int main = html.indexOf("= 0 ? html.substring(main) : html; + String[] parts = body.split("]*>"); + for (int i = 1; i < parts.length; i++) { + String name = htmlText(parts[i].split("")[0]).replaceFirst("^[\\d.\\s\\u00a0]+", ""); + List paras = new ArrayList<>(); + Matcher p = Pattern.compile("]*>(.*?)

", Pattern.DOTALL).matcher(parts[i]); + while (p.find() && paras.size() < 6) { + String t = htmlText(p.group(1)); + if (!t.isEmpty() && !t.contains("Was this helpful")) { + paras.add(t); + } + } + if (bitwig.get(name) instanceof Map entry && !paras.isEmpty()) { + String text = String.join(" ", paras); + ((Map) entry).put("bitwig_docs", + Map.of("text", text.length() > 1500 ? text.substring(0, 1500) : text, "url", USERGUIDE + page)); + found++; + } + } + } + } catch (Exception e) { + throw new BitwigApiException(ErrorCode.OPERATION_FAILED, "fetchDeviceDocs", + "Could not read Bitwig's user guide: " + e.getMessage()); + } + cat.put("docs_time", LocalDateTime.now().format(TIME)); + save(cat); + List missing = new ArrayList<>(); + bitwig.forEach((k, v) -> { + if (!(v instanceof Map m && m.containsKey("bitwig_docs"))) { + missing.add(k); + } + }); + Map out = new LinkedHashMap<>(); + out.put("pages_read", pages.size()); + out.put("devices_described", found); + out.put("without_description", missing); + return out; + } + + private static String get(java.net.http.HttpClient http, String url) throws Exception { + java.net.http.HttpResponse r = http.send( + java.net.http.HttpRequest.newBuilder(java.net.URI.create(url)).timeout(java.time.Duration.ofSeconds(60)).GET().build(), + java.net.http.HttpResponse.BodyHandlers.ofString()); + if (r.statusCode() != 200) { + throw new IllegalStateException(url + " returned " + r.statusCode()); + } + return r.body(); + } + + static String htmlText(String fragment) { + String t = fragment.replaceAll("<[^>]+>", " ").replaceAll("\\s+", " ").trim(); + return t.replace("&", "&").replace("<", "<").replace(">", ">").replace(""", "\"") + .replace("'", "'").replace(" ", " ").replace("’", "’"); + } + + // ======================================== + // Scan history + // ======================================== + + @SuppressWarnings("unchecked") + void recordScan(Map entry, Map scan, String source) { + String now = LocalDateTime.now().format(TIME); + scan.put("time", now); + scan.put("bitwig_version", bitwigVersion); + scan.put("source", source); + if (entry.get("version") != null) { + scan.put("plugin_version", entry.get("version")); + } + Map previous = entry.get("scan") instanceof Map p ? (Map) p : null; + Map h = new LinkedHashMap<>(); + h.put("time", now); + h.put("source", source); + h.put("bitwig_version", bitwigVersion); + if (scan.containsKey("error")) { + h.put("result", "error: " + scan.get("error")); + } else { + Map after = controlSummary(scan); + h.put("result", after.size() + " controls"); + if (previous != null && previous.containsKey("pages")) { + Map before = controlSummary(previous); + List changes = new ArrayList<>(); + after.forEach((k, v) -> { + if (!before.containsKey(k)) { + changes.add("added " + k); + } else if (!before.get(k).equals(v)) { + changes.add("changed " + k + ": " + before.get(k) + " -> " + v); + } + }); + before.keySet().stream().filter(k -> !after.containsKey(k)).forEach(k -> changes.add("removed " + k)); + h.put("changes", changes.isEmpty() ? List.of("none") : changes); + } + } + List history = entry.get("scan_history") instanceof List l ? new ArrayList<>(l) : new ArrayList<>(); + history.add(h); + while (history.size() > HISTORY_LIMIT) { + history.remove(0); + } + entry.put("scan_history", history); + entry.put("scan", scan); + } + + @SuppressWarnings("unchecked") + private static Map controlSummary(Map scan) { + Map out = new LinkedHashMap<>(); + for (Map pg : (List>) scan.getOrDefault("pages", List.of())) { + for (Map c : (List>) pg.get("controls")) { + String range = "options".equals(c.get("kind")) ? str(c.get("options")) : c.get("min") + ".." + c.get("max"); + out.putIfAbsent(str(c.get("name")), range); + } + } + return out; + } + + /** Coverage and freshness: how much is scanned, what failed, and what was scanned on an older Bitwig. */ + @SuppressWarnings("unchecked") + public Map status() { + Map cat = load(); + Map out = new LinkedHashMap<>(); + out.put("catalog_file", file.toString()); + out.put("bitwig_version", bitwigVersion); + out.put("inventory_time", cat.get("inventory_time")); + List> recent = new ArrayList<>(); + List stale = new ArrayList<>(); + List failed = new ArrayList<>(); + for (String section : SECTIONS) { + Map entries = (Map) cat.get(section); + int scanned = 0; + for (Map.Entry e : entries.entrySet()) { + Map entry = (Map) e.getValue(); + if (entry.get("scan") instanceof Map s) { + if (s.containsKey("error")) { + failed.add(section + ":" + e.getKey()); + } else { + scanned++; + if (bitwigVersion != null && s.get("bitwig_version") != null && !bitwigVersion.equals(s.get("bitwig_version"))) { + stale.add(section + ":" + e.getKey() + " (scanned on " + s.get("bitwig_version") + ")"); + } + } + } + if (entry.get("scan_history") instanceof List h) { + for (Object o : h) { + Map m = new LinkedHashMap<>((Map) o); + m.put("device", entry.getOrDefault("name", e.getKey())); + recent.add(m); + } + } + } + out.put(section, Map.of("entries", entries.size(), "scanned", scanned)); + } + recent.sort(Comparator.comparing((Map m) -> str(m.get("time"))).reversed()); + out.put("recent_scans", recent.size() > 15 ? recent.subList(0, 15) : recent); + out.put("stale", stale); + out.put("failed", failed); + out.put("guide_entries", guide.size()); + return out; + } + + private static String str(Object o) { + return o == null ? "" : String.valueOf(o); + } + + private static String truncate(String s, int n) { + return s.length() <= n ? s : s.substring(0, n - 1) + "…"; + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/EventBuffer.java b/src/main/java/io/github/fabb/wigai/bitwig/EventBuffer.java new file mode 100644 index 00000000..32ac360d --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/EventBuffer.java @@ -0,0 +1,106 @@ +package io.github.fabb.wigai.bitwig; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.LongSupplier; + +/** + * A bounded, numbered log of change events that readers poll by sequence number, optionally waiting for new + * ones. Rapid changes to the same thing (a fader being dragged) are coalesced: an event replaces the previous + * event with the same key when that one is younger than the coalescing window, so readers see the latest value + * without a flood. + */ +public final class EventBuffer { + + public record Event(long seq, long timeMs, String type, String key, Map data) { + public Map toMap() { + Map m = new LinkedHashMap<>(); + m.put("seq", seq); + m.put("time_ms", timeMs); + m.put("type", type); + m.putAll(data); + return m; + } + } + + private final int capacity; + private final long coalesceMs; + private final LongSupplier clock; + private final Deque events = new ArrayDeque<>(); + private final Map lastByKey = new HashMap<>(); + private long seq; + private long maxDroppedSeq; + + public EventBuffer(int capacity, long coalesceMs) { + this(capacity, coalesceMs, System::currentTimeMillis); + } + + EventBuffer(int capacity, long coalesceMs, LongSupplier clock) { + this.capacity = capacity; + this.coalesceMs = coalesceMs; + this.clock = clock; + } + + public synchronized void add(String type, String key, Map data) { + long now = clock.getAsLong(); + Event previous = lastByKey.get(key); + if (previous != null && now - previous.timeMs() < coalesceMs) { + events.remove(previous); + } + Event e = new Event(++seq, now, type, key, data); + events.addLast(e); + lastByKey.put(key, e); + while (events.size() > capacity) { + Event old = events.removeFirst(); + lastByKey.remove(old.key(), old); + maxDroppedSeq = old.seq(); + } + notifyAll(); + } + + /** The newest sequence number handed out so far (0 before any event). */ + public synchronized long latestSeq() { + return seq; + } + + /** + * Events after {@code since} whose type is in {@code types} (all types when null or empty), waiting up to + * {@code waitMs} for at least one to arrive. + */ + public synchronized List since(long since, Collection types, long waitMs) throws InterruptedException { + long deadline = clock.getAsLong() + Math.max(0, waitMs); + List found = collect(since, types); + while (found.isEmpty()) { + long left = deadline - clock.getAsLong(); + if (left <= 0) { + break; + } + wait(left); + found = collect(since, types); + } + return found; + } + + private List collect(long since, Collection types) { + List out = new ArrayList<>(); + for (Iterator it = events.iterator(); it.hasNext(); ) { + Event e = it.next(); + if (e.seq() > since && (types == null || types.isEmpty() || types.contains(e.type()))) { + out.add(e); + } + } + return out; + } + + /** Whether events after {@code since} were already pushed out of the buffer. */ + public synchronized boolean missedSince(long since) { + return since < maxDroppedSeq; + } +} diff --git a/src/main/java/io/github/fabb/wigai/bitwig/PluginCache.java b/src/main/java/io/github/fabb/wigai/bitwig/PluginCache.java new file mode 100644 index 00000000..589cdfa6 --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/bitwig/PluginCache.java @@ -0,0 +1,194 @@ +package io.github.fabb.wigai.bitwig; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Reads the plugin metadata Bitwig caches after scanning plugins ({@code ~/.BitwigStudio/cache/clap-metadata-N}, + * {@code vst3-metadata-N}, {@code vst-metadata-N}), so the installed plugins are known without loading any of them. + * + *

Each plugin file is a "BtWg" record of tagged fields: a 4-byte tag, a type byte (0x08 string, 0x19 string + * array, 0x03 int) and the value. One plugin file can describe several plugins; each one starts with its + * category field. The format is Bitwig's own and undocumented, so unknown bytes are skipped. + */ +public final class PluginCache { + + public record Plugin(String kind, String id, String name, String vendor, String category, String version, + List features, String file) {} + + private static final int TAG_PATH = 0x3ec; + private static final int TAG_CATEGORY = 0x3e3; + private static final int TAG_NAME = 0x3e4; + private static final int TAG_VENDOR = 0x3e6; + private static final int TAG_VST_ID = 0x3e2; + private static final int TAG_VST_VERSION = 0xc58; + private static final int TAG_CLAP_ID = 0x2ec4; + private static final int TAG_CLAP_VERSION = 0x2ec5; + private static final int TAG_CLAP_FEATURES = 0x34f8; + private static final int TYPE_INT = 0x03; + private static final int TYPE_STRING = 0x08; + private static final int TYPE_STRINGS = 0x19; + private static final int MAX_STRING = 4096; + private static final byte[] RECORD = "BtWg".getBytes(StandardCharsets.US_ASCII); + + /** Cache file prefix per plugin kind; the newest numbered file of each is read. */ + private static final Map FILES = Map.of("clap", "clap-metadata-", "vst3", "vst3-metadata-", + "vst2", "vst-metadata-"); + + private PluginCache() {} + + public static Path defaultDir() { + return Path.of(System.getProperty("user.home"), ".BitwigStudio", "cache"); + } + + /** Every plugin in the newest cache file of each kind; kinds without a cache file are skipped. */ + public static List read(Path dir) { + List out = new ArrayList<>(); + for (String kind : List.of("clap", "vst3", "vst2")) { + Path f = newest(dir, FILES.get(kind)); + if (f != null) { + try { + out.addAll(parse(kind, Files.readAllBytes(f))); + } catch (IOException e) { + // An unreadable cache just means those plugins are not listed. + } + } + } + return out; + } + + static Path newest(Path dir, String prefix) { + if (!Files.isDirectory(dir)) { + return null; + } + Pattern p = Pattern.compile(Pattern.quote(prefix) + "(\\d+)"); + Path best = null; + int bestN = -1; + try (Stream files = Files.list(dir)) { + for (Path f : (Iterable) files::iterator) { + Matcher m = p.matcher(f.getFileName().toString()); + if (m.matches() && Integer.parseInt(m.group(1)) > bestN) { + bestN = Integer.parseInt(m.group(1)); + best = f; + } + } + } catch (IOException e) { + return null; + } + return best; + } + + /** One plugin under construction; fields are filled in the order the cache lists them. */ + private static final class Builder { + final String file; + final String category; + String id; + String name; + String vendor; + String version; + List features = List.of(); + + Builder(String file, String category) { + this.file = file; + this.category = category; + } + } + + static List parse(String kind, byte[] b) { + List out = new ArrayList<>(); + for (int[] range : records(b)) { + String path = null; + Builder cur = null; + int i = range[0]; + while (i + 9 <= range[1]) { + int tag = readInt(b, i); + int type = b[i + 4] & 0xff; + int n = readInt(b, i + 5); + if (type == TYPE_STRING && isTag(tag) && n >= 0 && n < MAX_STRING && i + 9 + n <= range[1]) { + String v = new String(b, i + 9, n, StandardCharsets.UTF_8); + switch (tag) { + case TAG_PATH -> path = v; + case TAG_CATEGORY -> { + add(out, kind, cur); + cur = new Builder(path, v); + } + case TAG_NAME -> { if (cur != null && cur.name == null) cur.name = v; } + case TAG_VENDOR -> { if (cur != null && cur.vendor == null) cur.vendor = v; } + case TAG_CLAP_ID, TAG_VST_ID -> { if (cur != null && cur.id == null) cur.id = v; } + case TAG_CLAP_VERSION, TAG_VST_VERSION -> { if (cur != null && cur.version == null) cur.version = v; } + default -> { } + } + i += 9 + n; + continue; + } + if (type == TYPE_INT && tag == TAG_VST_ID && cur != null && cur.id == null) { + cur.id = String.valueOf(n); + i += 9; + continue; + } + if (type == TYPE_STRINGS && tag == TAG_CLAP_FEATURES && cur != null && n > 0 && n < 128) { + List list = new ArrayList<>(); + int j = i + 9; + for (int k = 0; k < n && j + 4 <= range[1]; k++) { + int len = readInt(b, j); + if (len < 0 || len > MAX_STRING || j + 4 + len > range[1]) { + break; + } + list.add(new String(b, j + 4, len, StandardCharsets.UTF_8)); + j += 4 + len; + } + cur.features = List.copyOf(list); + i = j; + continue; + } + i++; + } + add(out, kind, cur); + } + return out; + } + + private static boolean isTag(int tag) { + return tag == TAG_PATH || tag == TAG_CATEGORY || tag == TAG_NAME || tag == TAG_VENDOR || tag == TAG_VST_ID + || tag == TAG_VST_VERSION || tag == TAG_CLAP_ID || tag == TAG_CLAP_VERSION; + } + + private static void add(List out, String kind, Builder p) { + if (p != null && p.id != null && !p.id.isEmpty() && p.name != null) { + out.add(new Plugin(kind, p.id, p.name, p.vendor, p.category, p.version == null || p.version.isEmpty() + ? null : p.version, p.features, p.file)); + } + } + + /** Byte ranges between successive "BtWg" markers. */ + private static List records(byte[] b) { + List starts = new ArrayList<>(); + outer: + for (int i = 0; i + RECORD.length <= b.length; i++) { + for (int j = 0; j < RECORD.length; j++) { + if (b[i + j] != RECORD[j]) { + continue outer; + } + } + starts.add(i + RECORD.length); + } + List ranges = new ArrayList<>(); + for (int k = 0; k < starts.size(); k++) { + int end = k + 1 < starts.size() ? starts.get(k + 1) - RECORD.length : b.length; + ranges.add(new int[]{starts.get(k), end}); + } + return ranges; + } + + private static int readInt(byte[] b, int i) { + return ((b[i] & 0xff) << 24) | ((b[i + 1] & 0xff) << 16) | ((b[i + 2] & 0xff) << 8) | (b[i + 3] & 0xff); + } +} diff --git a/src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java b/src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java index 0493c538..d1a3d8e5 100644 --- a/src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java +++ b/src/main/java/io/github/fabb/wigai/mcp/McpServerManager.java @@ -2,6 +2,12 @@ import io.github.fabb.wigai.WigAIExtensionDefinition; import io.github.fabb.wigai.bitwig.BitwigApiFacade; +import io.github.fabb.wigai.bitwig.BitwigEditFacade; +import io.github.fabb.wigai.bitwig.BitwigContainerFacade; +import io.github.fabb.wigai.bitwig.BitwigEventStream; +import io.github.fabb.wigai.bitwig.BitwigProjectFacade; +import io.github.fabb.wigai.bitwig.BitwigSessionFacade; +import io.github.fabb.wigai.bitwig.DeviceCatalog; import io.github.fabb.wigai.common.Logger; import io.github.fabb.wigai.common.logging.StructuredLogger; import io.github.fabb.wigai.config.ConfigManager; @@ -26,6 +32,10 @@ import io.modelcontextprotocol.spec.McpSchema; import com.bitwig.extension.controller.api.ControllerHost; import io.github.fabb.wigai.mcp.tool.SceneByNameTool; +import io.github.fabb.wigai.mcp.tool.EditTools; + +import java.util.ArrayList; +import java.util.List; /** * Manages the MCP server for the WigAI extension. @@ -41,6 +51,21 @@ * - Registers the MCP servlet with the provided ServletContextHandler */ public class McpServerManager { + /** Sent to clients on initialize; the full guide is docs/AGENT-GUIDE.md in the repository. */ + static final String AGENT_INSTRUCTIONS = """ + WigAI controls the Bitwig Studio project that is open right now. + Start with status and list_tracks. Read before you write: get_track_mixer, get_device_chains and \ + get_device_parameters do not move the user's selection. If something is silent, check project_state \ + (a stray solo or mute) and set_engine_active. + To choose devices, use list_installed_devices (Bitwig devices and every scanned plugin) and \ + describe_device (controls, scanned on demand, and Bitwig's own description). Presets: list_presets, \ + or browser_open with mode replace on a device. + To follow what the user does, call get_events once for a starting point, then pass next_seq back \ + with wait_ms; watch streams changes as notifications. + The API cannot change routing (track outputs, Audio Receiver sources), place arranger clips, move \ + tracks, add modulators or automation, or add layers: ask the user to do those by hand. + Edits are undoable with undo; suggest saving before large changes."""; + private final Logger logger; private final WigAIExtensionDefinition extensionDefinition; private final ControllerHost controllerHost; @@ -52,6 +77,12 @@ public class McpServerManager { private TransportController transportController; private DeviceController deviceController; private ClipSceneController clipSceneController; + private BitwigEditFacade bitwigEditFacade; + private BitwigSessionFacade bitwigSessionFacade; + private DeviceCatalog deviceCatalog; + private BitwigProjectFacade bitwigProjectFacade; + private BitwigContainerFacade bitwigContainerFacade; + private BitwigEventStream bitwigEventStream; /** * Creates a new McpServerManager instance. @@ -124,6 +155,18 @@ public ServletHolder createMcpServlet(String endpointPath) throws Exception { transportController = new TransportController(bitwigApiFacade, logger); deviceController = new DeviceController(bitwigApiFacade, logger); clipSceneController = new ClipSceneController(bitwigApiFacade, logger); + bitwigEditFacade = new BitwigEditFacade(getHost(), logger, bitwigApiFacade.getApplication(), + bitwigApiFacade.getTransport(), bitwigApiFacade.getTrackBank(), bitwigApiFacade.getTrackDeviceBanks()); + bitwigSessionFacade = new BitwigSessionFacade(getHost(), logger, bitwigApiFacade.getTransport(), + bitwigApiFacade.getTrackBank()); + bitwigProjectFacade = new BitwigProjectFacade(getHost(), logger, bitwigApiFacade.getTransport(), + bitwigApiFacade.getTrackBank(), bitwigEditFacade); + bitwigContainerFacade = new BitwigContainerFacade(getHost(), logger, bitwigEditFacade, + BitwigContainerFacade.defaultPresetRoots()); + bitwigEventStream = new BitwigEventStream(getHost(), logger, bitwigApiFacade.getApplication(), + bitwigApiFacade.getTransport(), bitwigApiFacade.getTrackBank()); + deviceCatalog = new DeviceCatalog(DeviceCatalog.defaultFile(), bitwigEditFacade, logger, + getHost().getHostVersion()); } else { logger.info("McpServerManager: Reusing existing Bitwig API controllers"); } @@ -131,29 +174,38 @@ public ServletHolder createMcpServlet(String endpointPath) throws Exception { // Create StructuredLogger for tools that have been migrated to unified error handling StructuredLogger structuredLogger = new StructuredLogger(logger, "MCP-Tools"); + List tools = new ArrayList<>(List.of( + StatusTool.specification(this.extensionDefinition, bitwigApiFacade, structuredLogger), + TransportTool.transportStartSpecification(transportController, structuredLogger), + TransportTool.transportStopSpecification(transportController, structuredLogger), + ClipTool.launchClipSpecification(clipSceneController, structuredLogger), + SceneTool.launchSceneByIndexSpecification(clipSceneController, structuredLogger), + SceneByNameTool.launchSceneByNameSpecification(clipSceneController, structuredLogger), + DeviceParamTool.getSelectedDeviceParametersSpecification(deviceController, structuredLogger), + DeviceParamTool.setSelectedDeviceParameterSpecification(deviceController, structuredLogger), + DeviceParamTool.setMultipleDeviceParametersSpecification(deviceController, structuredLogger), + GetDeviceDetailsTool.getDeviceDetailsSpecification(deviceController, structuredLogger), + ListTracksTool.specification(bitwigApiFacade, structuredLogger), + ListDevicesOnTrackTool.specification(bitwigApiFacade, structuredLogger), + GetTrackDetailsTool.specification(bitwigApiFacade, structuredLogger), + ListScenesTool.specification(bitwigApiFacade, structuredLogger), + GetClipsInSceneTool.getClipsInSceneSpecification(clipSceneController, structuredLogger) + )); + tools.addAll(EditTools.specifications(bitwigEditFacade, structuredLogger)); + tools.addAll(EditTools.sessionSpecifications(bitwigEditFacade, bitwigSessionFacade, structuredLogger)); + tools.addAll(EditTools.catalogSpecifications(bitwigEditFacade, deviceCatalog, structuredLogger)); + tools.addAll(EditTools.eventSpecifications(bitwigEditFacade, bitwigEventStream, structuredLogger)); + tools.addAll(EditTools.containerSpecifications(bitwigEditFacade, bitwigContainerFacade, structuredLogger)); + tools.addAll(EditTools.projectSpecifications(bitwigEditFacade, bitwigSessionFacade, bitwigProjectFacade, structuredLogger)); + McpServer.sync(this.transportProvider) .serverInfo("WigAI", extensionDefinition.getVersion()) + .instructions(AGENT_INSTRUCTIONS) .capabilities(McpSchema.ServerCapabilities.builder() .tools(true) .logging() .build()) - .tools( - StatusTool.specification(this.extensionDefinition, bitwigApiFacade, structuredLogger), - TransportTool.transportStartSpecification(transportController, structuredLogger), - TransportTool.transportStopSpecification(transportController, structuredLogger), - ClipTool.launchClipSpecification(clipSceneController, structuredLogger), - SceneTool.launchSceneByIndexSpecification(clipSceneController, structuredLogger), - SceneByNameTool.launchSceneByNameSpecification(clipSceneController, structuredLogger), - DeviceParamTool.getSelectedDeviceParametersSpecification(deviceController, structuredLogger), - DeviceParamTool.setSelectedDeviceParameterSpecification(deviceController, structuredLogger), - DeviceParamTool.setMultipleDeviceParametersSpecification(deviceController, structuredLogger), - GetDeviceDetailsTool.getDeviceDetailsSpecification(deviceController, structuredLogger), - ListTracksTool.specification(bitwigApiFacade, structuredLogger), - ListDevicesOnTrackTool.specification(bitwigApiFacade, structuredLogger), - GetTrackDetailsTool.specification(bitwigApiFacade, structuredLogger), - ListScenesTool.specification(bitwigApiFacade, structuredLogger), - GetClipsInSceneTool.getClipsInSceneSpecification(clipSceneController, structuredLogger) - ) + .tools(tools) .build(); // 4. Return the MCP servlet diff --git a/src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java b/src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java new file mode 100644 index 00000000..ed136f3b --- /dev/null +++ b/src/main/java/io/github/fabb/wigai/mcp/tool/EditTools.java @@ -0,0 +1,1023 @@ +package io.github.fabb.wigai.mcp.tool; + +import io.github.fabb.wigai.bitwig.BitwigContainerFacade; +import io.github.fabb.wigai.bitwig.BitwigEventStream; +import io.github.fabb.wigai.bitwig.BitwigEditFacade; +import io.github.fabb.wigai.bitwig.BitwigProjectFacade; +import io.github.fabb.wigai.bitwig.BitwigSessionFacade; +import io.github.fabb.wigai.bitwig.DeviceCatalog; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.logging.StructuredLogger; +import io.github.fabb.wigai.mcp.McpErrorHandler; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; + +/** + * MCP tools that change the project: tracks, mixer, devices, device parameters, clips, notes, + * transport settings and Bitwig application actions. All of them go through {@link BitwigEditFacade}. + */ +public class EditTools { + + private static final String TRACK_PROPS = """ + "track_index": {"type": "integer", "minimum": 0, "description": "0-based track index (see list_tracks)"}, + "track_name": {"type": "string", "description": "Exact track name (alternative to track_index)"}"""; + + private static final String DEVICE_PROPS = TRACK_PROPS + """ + , + "device_index": {"type": "integer", "minimum": 0, "description": "0-based device index on the track (see list_devices_on_track)"}"""; + + private static final String CHAIN_PROPS = """ + "chain": {"type": "string", "description": "A chain inside the device, e.g. 'Modulator' or 'Carrier' (Vocoder), 'FX' (FX devices), or 'layer:0' / 'layer:Name' (layer devices). See get_device_chains."}, + "chain_device_index": {"type": "integer", "minimum": 0, "description": "Device index within that chain (required with chain)"}"""; + + private static final String ARRANGER_PROP = """ + "arranger": {"type": "boolean", "description": "Use the clip selected in Bitwig's arranger instead of a launcher slot"}"""; + + private static final String POSITION_PROPS = """ + "position": {"type": "string", "enum": ["start", "end"], "description": "Where in the chain to insert (default end)"}, + "after_device_index": {"type": "integer", "minimum": 0, "description": "Insert directly after this device instead"}"""; + + @FunctionalInterface + private interface Op { + Object run(Args a) throws Exception; + } + + /** A tool body that also needs the MCP exchange (to send notifications) and the request's progress token. */ + @FunctionalInterface + private interface StreamingOp { + Object run(Args a, McpSyncServerExchange exchange, Object progressToken) throws Exception; + } + + /** Thin typed accessor over raw MCP arguments. */ + static final class Args { + private final Map raw; + private final BitwigEditFacade facade; + private final String operation; + + Args(Map raw, BitwigEditFacade facade, String operation) { + this.raw = raw == null ? Map.of() : raw; + this.facade = facade; + this.operation = operation; + } + + Integer integer(String key) { + Object v = raw.get(key); + if (v == null) { + return null; + } + if (!(v instanceof Number n)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be an integer"); + } + return n.intValue(); + } + + int requiredInt(String key) { + Integer v = integer(key); + if (v == null) { + throw new IllegalArgumentException("Missing required parameter '" + key + "'"); + } + return v; + } + + Double number(String key) { + Object v = raw.get(key); + if (v == null) { + return null; + } + if (!(v instanceof Number n)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be a number"); + } + return n.doubleValue(); + } + + String string(String key) { + Object v = raw.get(key); + if (v == null) { + return null; + } + if (!(v instanceof String s)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be a string"); + } + return s; + } + + String requiredString(String key) { + String v = string(key); + if (v == null || v.isBlank()) { + throw new IllegalArgumentException("Missing required parameter '" + key + "'"); + } + return v; + } + + Boolean bool(String key) { + Object v = raw.get(key); + if (v == null) { + return null; + } + if (!(v instanceof Boolean b)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be a boolean"); + } + return b; + } + + boolean requiredBool(String key) { + Boolean v = bool(key); + if (v == null) { + throw new IllegalArgumentException("Missing required parameter '" + key + "'"); + } + return v; + } + + @SuppressWarnings("unchecked") + List> list(String key) { + Object v = raw.get(key); + if (!(v instanceof List l) || l.isEmpty()) { + throw new IllegalArgumentException("Parameter '" + key + "' must be a non-empty array"); + } + List> out = new ArrayList<>(); + for (Object o : l) { + if (!(o instanceof Map)) { + throw new IllegalArgumentException("Items of '" + key + "' must be objects"); + } + out.add((Map) o); + } + return out; + } + + Map stringMap(String key) { + Object v = raw.get(key); + if (v == null) { + return Map.of(); + } + if (!(v instanceof Map m)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be an object"); + } + Map out = new java.util.LinkedHashMap<>(); + for (Map.Entry e : m.entrySet()) { + if (!(e.getValue() instanceof String sv)) { + throw new IllegalArgumentException("Values of '" + key + "' must be strings"); + } + out.put(String.valueOf(e.getKey()), sv); + } + return out; + } + + /** The track for a launcher clip, or null when the arranger's selected clip is meant. */ + Integer clipTrack() throws BitwigApiException { + return arranger() ? null : track(); + } + + boolean arranger() { + return Boolean.TRUE.equals(bool("arranger")); + } + + List stringList(String key) { + Object v = raw.get(key); + if (v == null) { + return List.of(); + } + if (!(v instanceof List l)) { + throw new IllegalArgumentException("Parameter '" + key + "' must be an array of strings"); + } + List out = new ArrayList<>(); + for (Object o : l) { + if (!(o instanceof String str)) { + throw new IllegalArgumentException("Items of '" + key + "' must be strings"); + } + out.add(str); + } + return out; + } + + int track() throws BitwigApiException { + return facade.resolveTrackIndex(integer("track_index"), string("track_name"), operation); + } + + int track(String indexKey, String nameKey) throws BitwigApiException { + return facade.resolveTrackIndex(integer(indexKey), string(nameKey), operation); + } + } + + private static McpServerFeatures.SyncToolSpecification tool(String name, String description, String properties, + String required, BitwigEditFacade facade, + StructuredLogger logger, Op op) { + String schema = "{\"type\": \"object\", \"properties\": {" + properties + "}" + + (required == null ? "" : ", \"required\": [" + required + "]") + + ", \"additionalProperties\": false}"; + var t = McpSchema.Tool.builder().name(name).description(description).inputSchema(schema).build(); + BiFunction handler = + (exchange, req) -> McpErrorHandler.executeWithErrorHandling(name, logger, + () -> op.run(new Args(req.arguments(), facade, name))); + return McpServerFeatures.SyncToolSpecification.builder().tool(t).callHandler(handler).build(); + } + + private static McpServerFeatures.SyncToolSpecification streamingTool(String name, String description, + String properties, BitwigEditFacade facade, + StructuredLogger logger, StreamingOp op) { + String schema = "{\"type\": \"object\", \"properties\": {" + properties + "}, \"additionalProperties\": false}"; + var t = McpSchema.Tool.builder().name(name).description(description).inputSchema(schema).build(); + BiFunction handler = + (exchange, req) -> McpErrorHandler.executeWithErrorHandling(name, logger, () -> op.run( + new Args(req.arguments(), facade, name), exchange, + req.meta() == null ? null : req.meta().get("progressToken"))); + return McpServerFeatures.SyncToolSpecification.builder().tool(t).callHandler(handler).build(); + } + + /** Following changes in Bitwig: a pollable event log, and a call that streams events as notifications. */ + public static List eventSpecifications( + BitwigEditFacade f, BitwigEventStream events, StructuredLogger log) { + List specs = new ArrayList<>(); + String typesProp = "\"types\": {\"type\": \"array\", \"items\": {\"type\": \"string\", \"enum\": " + + "[\"transport\", \"project\", \"track\", \"clip\", \"selection\", \"parameter\"]}, " + + "\"description\": \"Only these kinds of change (default all)\"}"; + + specs.add(tool("get_events", + "Changes in Bitwig since since_seq: transport (play, record, loop, tempo), project (switch, engine), " + + "track (name, mute, solo, arm, volume, pan, added/removed), clip (launcher playback state, " + + "clips created/deleted), selection (the user's selected track, device, preset) and parameter (the " + + "selected device's knobs as the user turns them). Call without since_seq to get a starting point, " + + "then pass next_seq back. wait_ms waits up to that long for the next change (long-poll). Rapid " + + "changes to the same thing are merged into the latest value.", + """ + "since_seq": {"type": "integer", "minimum": 0}, + "wait_ms": {"type": "integer", "minimum": 0, "maximum": 30000}, + """ + typesProp, + null, f, log, + a -> events.events(a.integer("since_seq") == null ? null : a.integer("since_seq").longValue(), + a.stringList("types"), a.integer("wait_ms") == null ? 0 : a.integer("wait_ms")))); + + specs.add(streamingTool("watch", + "Watch Bitwig for duration_ms (up to 60000) and stream each change as it happens: as an MCP progress " + + "notification (when the call carries a progress token) and as a log message (logger " + + "'wigai.events'). Returns every change seen, so clients that do not show notifications still get them.", + """ + "duration_ms": {"type": "integer", "minimum": 100, "maximum": 60000}, + """ + typesProp, + f, log, + (a, exchange, token) -> { + long duration = a.integer("duration_ms") == null ? 10000 : a.integer("duration_ms"); + List types = a.stringList("types"); + long deadline = System.currentTimeMillis() + duration; + long since = events.buffer().latestSeq(); + List> seen = new ArrayList<>(); + var json = new com.fasterxml.jackson.databind.ObjectMapper(); + while (System.currentTimeMillis() < deadline) { + for (var e : events.buffer().since(since, types, deadline - System.currentTimeMillis())) { + since = e.seq(); + Map m = e.toMap(); + seen.add(m); + String text = json.writeValueAsString(m); + if (exchange != null) { + if (token != null) { + exchange.progressNotification(new McpSchema.ProgressNotification(String.valueOf(token), + (double) seen.size(), null, text)); + } + exchange.loggingNotification(new McpSchema.LoggingMessageNotification( + McpSchema.LoggingLevel.INFO, "wigai.events", text)); + } + } + } + Map result = new java.util.LinkedHashMap<>(); + result.put("duration_ms", duration); + result.put("events", seen); + result.put("next_seq", since); + result.put("now", events.now()); + return result; + })); + return specs; + } + + public static List specifications(BitwigEditFacade f, StructuredLogger log) { + List specs = new ArrayList<>(); + + // ---- tracks + specs.add(tool("create_track", + "Create a new audio, instrument or effect track, optionally at a position and with a name.", + """ + "type": {"type": "string", "enum": ["audio", "instrument", "effect"]}, + "position": {"type": "integer", "minimum": 0, "description": "Track index to insert at (default: end)"}, + "name": {"type": "string"}""", + "\"type\"", f, log, + a -> f.createTrack(a.requiredString("type"), a.integer("position"), a.string("name")))); + + specs.add(tool("delete_track", "Delete a track.", TRACK_PROPS, null, f, log, + a -> f.deleteTrack(a.track()))); + + specs.add(tool("duplicate_track", "Duplicate a track with its devices and clips.", TRACK_PROPS, null, f, log, + a -> f.duplicateTrack(a.track()))); + + specs.add(tool("get_track_mixer", "Read a track's mixer state: volume, pan, mute, solo, arm, monitor mode, sends.", + TRACK_PROPS, null, f, log, a -> f.trackMixerState(a.track()))); + + specs.add(tool("set_track", + "Change track settings. Only given fields change. volume_db is matched exactly against Bitwig's dB display; " + + "volume is the raw 0-1 fader position. pan is -1 (left) to 1 (right).", + TRACK_PROPS + """ + , + "name": {"type": "string"}, + "volume": {"type": "number", "minimum": 0, "maximum": 1}, + "volume_db": {"type": "number", "maximum": 6}, + "pan": {"type": "number", "minimum": -1, "maximum": 1}, + "mute": {"type": "boolean"}, + "solo": {"type": "boolean"}, + "arm": {"type": "boolean"}, + "activated": {"type": "boolean"}, + "monitor_mode": {"type": "string", "enum": ["ON", "OFF", "AUTO"]}, + "color": {"type": "string", "description": "#RRGGBB"}, + "group_expanded": {"type": "boolean", "description": "Expand or collapse a group track"}, + "crossfade_mode": {"type": "string", "enum": ["A", "B", "AB"]}""", + null, f, log, + a -> f.setTrackProperties(a.track(), a.string("name"), a.number("volume"), a.number("volume_db"), + a.number("pan"), a.bool("mute"), a.bool("solo"), a.bool("arm"), a.bool("activated"), + a.string("monitor_mode"), a.string("color"), a.bool("group_expanded"), a.string("crossfade_mode")))); + + specs.add(tool("set_send", "Set a track's send level (0-1) and/or enable it.", + TRACK_PROPS + """ + , + "send_index": {"type": "integer", "minimum": 0}, + "value": {"type": "number", "minimum": 0, "maximum": 1}, + "enabled": {"type": "boolean"}""", + "\"send_index\"", f, log, + a -> f.setSend(a.track(), a.requiredInt("send_index"), a.number("value"), a.bool("enabled")))); + + // ---- devices + specs.add(tool("list_bitwig_devices", + "List Bitwig's built-in devices usable with insert_device, with type (instrument, audio effect, " + + "note effect) and category. Optional filter matches name, type or category.", + "\"filter\": {\"type\": \"string\"}", null, f, log, a -> f.listBitwigDevices(a.string("filter")))); + + specs.add(tool("insert_device", + "Insert a device on a track: a Bitwig built-in by name (e.g. 'EQ+', 'Audio Receiver', 'Reverb'), " + + "a plugin by CLAP id (e.g. 'org.surge-synth-team.surge-xt'), VST3 class id or VST2 id " + + "(see list_installed_devices), " + + "or a preset file (.bwpreset path, see list_presets). " + + "To insert inside a device's chain, give device_index (the container) and chain; " + + "after_device_index then refers to a device in that chain.", + TRACK_PROPS + ",\n" + POSITION_PROPS + """ + , + "device_index": {"type": "integer", "minimum": 0, "description": "Container device, only with chain"}, + "chain": {"type": "string", "description": "Chain inside device_index, e.g. 'Modulator'. See get_device_chains."}, + "bitwig_device": {"type": "string"}, + "clap_id": {"type": "string"}, + "vst3_id": {"type": "string"}, + "vst2_id": {"type": "integer"}, + "preset_file": {"type": "string", "description": "Absolute path of a .bwpreset (or other device file)"}""", + null, f, log, + a -> f.insertDevice(a.track(), a.integer("device_index"), a.string("chain"), a.string("bitwig_device"), + a.string("clap_id"), a.string("vst3_id"), a.integer("vst2_id"), a.string("preset_file"), + a.string("position"), a.integer("after_device_index")))); + + specs.add(tool("replace_device", + "Replace a device (or a device inside a chain) in place with a Bitwig device, a plugin, or a preset file. " + + "Loading a preset onto an existing device is a replace with preset_file.", + DEVICE_PROPS + ",\n" + CHAIN_PROPS + """ + , + "bitwig_device": {"type": "string"}, + "clap_id": {"type": "string"}, + "vst3_id": {"type": "string"}, + "vst2_id": {"type": "integer"}, + "preset_file": {"type": "string", "description": "Absolute path of a .bwpreset (or other device file)"}""", + "\"device_index\"", f, log, + a -> f.replaceDevice(a.track(), a.requiredInt("device_index"), a.string("chain"), a.integer("chain_device_index"), + a.string("bitwig_device"), a.string("clap_id"), a.string("vst3_id"), a.integer("vst2_id"), + a.string("preset_file")))); + + specs.add(tool("get_device_chains", + "List the chains inside a device and the devices in each: slots such as a Vocoder's 'Modulator' and " + + "'Carrier' or an FX device's 'FX', and layers of FX/Instrument Layer devices.", + DEVICE_PROPS, "\"device_index\"", f, log, + a -> f.getDeviceChains(a.track(), a.requiredInt("device_index")))); + + String copyProps = """ + "from_track_index": {"type": "integer", "minimum": 0}, + "from_track_name": {"type": "string"}, + "from_device_index": {"type": "integer", "minimum": 0}, + "from_chain": {"type": "string", "description": "Source is inside this chain of from_device_index"}, + "from_chain_device_index": {"type": "integer", "minimum": 0}, + "to_track_index": {"type": "integer", "minimum": 0}, + "to_track_name": {"type": "string"}, + "to_device_index": {"type": "integer", "minimum": 0, "description": "Container device, only with to_chain"}, + "to_chain": {"type": "string", "description": "Destination chain inside to_device_index"}, + """ + POSITION_PROPS; + specs.add(tool("copy_device", "Copy a device (with all its settings) to another position or track.", + copyProps, "\"from_device_index\"", f, log, + a -> f.copyOrMoveDevice(a.track("from_track_index", "from_track_name"), a.requiredInt("from_device_index"), + a.string("from_chain"), a.integer("from_chain_device_index"), + a.track("to_track_index", "to_track_name"), a.integer("to_device_index"), a.string("to_chain"), + a.string("position"), a.integer("after_device_index"), false))); + specs.add(tool("move_device", "Move a device to another position or track.", + copyProps, "\"from_device_index\"", f, log, + a -> f.copyOrMoveDevice(a.track("from_track_index", "from_track_name"), a.requiredInt("from_device_index"), + a.string("from_chain"), a.integer("from_chain_device_index"), + a.track("to_track_index", "to_track_name"), a.integer("to_device_index"), a.string("to_chain"), + a.string("position"), a.integer("after_device_index"), true))); + + specs.add(tool("delete_device", "Delete a device from a track, or from a chain inside a device.", + DEVICE_PROPS + ",\n" + CHAIN_PROPS, "\"device_index\"", f, log, + a -> f.deleteDevice(a.track(), a.requiredInt("device_index"), a.string("chain"), a.integer("chain_device_index")))); + + specs.add(tool("set_device_enabled", "Enable (un-bypass) or bypass a device, or a device inside a chain.", + DEVICE_PROPS + ",\n" + CHAIN_PROPS + ",\n\"enabled\": {\"type\": \"boolean\"}", "\"device_index\", \"enabled\"", f, log, + a -> f.setDeviceEnabled(a.track(), a.requiredInt("device_index"), a.string("chain"), a.integer("chain_device_index"), + a.requiredBool("enabled")))); + + specs.add(tool("set_device_window_open", "Open or close a plugin's editor window.", + DEVICE_PROPS + ",\n\"open\": {\"type\": \"boolean\"}", "\"device_index\", \"open\"", f, log, + a -> f.setDeviceWindowOpen(a.track(), a.requiredInt("device_index"), a.requiredBool("open")))); + + specs.add(tool("get_device_parameters", + "Read a device's parameters on any track without changing the user's selection. " + + "Returns all remote-control pages, or one page if 'page' is given. A control that a modulator " + + "(LFO, envelope, macro) is moving also has modulated_value/modulated_display, its effective value now.", + DEVICE_PROPS + ",\n" + CHAIN_PROPS + ",\n\"page\": {\"type\": \"integer\", \"minimum\": 0}", "\"device_index\"", f, log, + a -> f.getDeviceParameters(a.track(), a.requiredInt("device_index"), a.string("chain"), + a.integer("chain_device_index"), a.integer("page")))); + + specs.add(tool("set_device_parameters", + "Set parameters on any device by name (case-insensitive, searched across pages) or by page+index. " + + "Values are normalized 0-1; the response shows Bitwig's displayed value so you can check units.", + DEVICE_PROPS + ",\n" + CHAIN_PROPS + """ + , + "verify": {"type": "boolean", "description": "Wait for each value to be confirmed (default true); false is faster for bulk writes"}, + "parameters": {"type": "array", "items": {"type": "object", "properties": { + "name": {"type": "string"}, + "page": {"type": "integer", "minimum": 0}, + "index": {"type": "integer", "minimum": 0, "maximum": 7}, + "value": {"type": "number", "minimum": 0, "maximum": 1}}, + "required": ["value"]}}""", + "\"device_index\", \"parameters\"", f, log, + a -> { + List list = new ArrayList<>(); + for (Map p : a.list("parameters")) { + Args pa = new Args(p, f, "set_device_parameters"); + Double v = pa.number("value"); + if (v == null) { + throw new IllegalArgumentException("Each parameter needs a 'value'"); + } + list.add(new BitwigEditFacade.ParamAssignment(pa.integer("page"), pa.integer("index"), pa.string("name"), v)); + } + return f.setDeviceParameters(a.track(), a.requiredInt("device_index"), a.string("chain"), + a.integer("chain_device_index"), list, !Boolean.FALSE.equals(a.bool("verify"))); + })); + + // ---- clips and files + specs.add(tool("insert_file", + "Import an audio or MIDI file. With slot_index it goes into that clip-launcher slot of the track; " + + "without, Bitwig inserts it as a new track after the given track.", + TRACK_PROPS + """ + , + "path": {"type": "string", "description": "Absolute path to a .wav/.mid/etc file"}, + "slot_index": {"type": "integer", "minimum": 0}""", + "\"path\"", f, log, + a -> f.insertFile(a.track(), a.integer("slot_index"), a.requiredString("path")))); + + specs.add(tool("create_clip", "Create an empty launcher clip in a slot.", + TRACK_PROPS + """ + , + "slot_index": {"type": "integer", "minimum": 0}, + "length_beats": {"type": "integer", "minimum": 1}""", + "\"slot_index\", \"length_beats\"", f, log, + a -> f.createClip(a.track(), a.requiredInt("slot_index"), a.requiredInt("length_beats")))); + + specs.add(tool("delete_clip", "Delete the launcher clip in a slot.", + TRACK_PROPS + ",\n\"slot_index\": {\"type\": \"integer\", \"minimum\": 0}", "\"slot_index\"", f, log, + a -> f.deleteClip(a.track(), a.requiredInt("slot_index")))); + + specs.add(tool("write_notes", + "Write MIDI notes into an existing launcher clip. Times are in beats from the clip start (1/64-beat grid).", + TRACK_PROPS + """ + , + "slot_index": {"type": "integer", "minimum": 0}, + "clear_first": {"type": "boolean", "description": "Remove existing notes first (default false)"}, + "notes": {"type": "array", "items": {"type": "object", "properties": { + "start": {"type": "number", "minimum": 0}, + "pitch": {"type": "integer", "minimum": 0, "maximum": 127}, + "velocity": {"type": "integer", "minimum": 1, "maximum": 127}, + "duration": {"type": "number", "exclusiveMinimum": 0}}, + "required": ["start", "pitch", "duration"]}}""", + "\"slot_index\", \"notes\"", f, log, + a -> { + List notes = new ArrayList<>(); + for (Map n : a.list("notes")) { + Args na = new Args(n, f, "write_notes"); + Integer vel = na.integer("velocity"); + notes.add(new BitwigEditFacade.Note(na.number("start"), na.requiredInt("pitch"), + vel == null ? 100 : vel, na.number("duration"))); + } + Boolean clear = a.bool("clear_first"); + return f.writeNotes(a.track(), a.requiredInt("slot_index"), notes, Boolean.TRUE.equals(clear)); + })); + + // ---- transport + specs.add(tool("set_transport", "Set tempo, play position (beats), arranger loop, metronome, record or overdub.", + """ + "tempo_bpm": {"type": "number", "minimum": 20, "maximum": 666}, + "position_beats": {"type": "number", "minimum": 0}, + "loop": {"type": "boolean"}, + "metronome": {"type": "boolean"}, + "record": {"type": "boolean"}, + "overdub": {"type": "boolean"}""", + null, f, log, + a -> f.setTransport(a.number("tempo_bpm"), a.number("position_beats"), a.bool("loop"), + a.bool("metronome"), a.bool("record"), a.bool("overdub")))); + + // ---- application + specs.add(tool("list_actions", + "List Bitwig application actions (menu commands), optionally filtered by a substring of id/name/category.", + "\"filter\": {\"type\": \"string\"}", null, f, log, a -> f.listActions(a.string("filter")))); + + specs.add(tool("invoke_action", "Run a Bitwig application action by id (see list_actions).", + "\"id\": {\"type\": \"string\"}", "\"id\"", f, log, a -> f.invokeAction(a.requiredString("id")))); + + specs.add(tool("undo", "Undo the last change in Bitwig.", "", null, f, log, a -> f.undo(false))); + specs.add(tool("redo", "Redo the last undone change in Bitwig.", "", null, f, log, a -> f.undo(true))); + + return specs; + } + + /** Device knowledge: what is installed, what it is for, and what its controls do. */ + public static List catalogSpecifications( + BitwigEditFacade f, DeviceCatalog c, StructuredLogger log) { + List specs = new ArrayList<>(); + + specs.add(tool("list_installed_devices", + "Find devices and plugins installed on this machine (Bitwig built-ins, CLAP plugins, LV2 plugins usable " + + "through Carla). The query is matched against names, vendors, categories, descriptions and the " + + "device guide's 'use for' text, best matches first; e.g. 'vocal compressor', 'reverb', 'pitch'. " + + "Use it to pick candidates before recommending or inserting a device.", + """ + "query": {"type": "string"}, + "kind": {"type": "string", "enum": ["bitwig", "clap", "lv2"]}""", + null, f, log, a -> c.listInstalled(a.string("query"), a.string("kind")))); + + specs.add(tool("describe_device", + "Everything known about one device: what it is for and how to use it (device guide), its plugin " + + "description, its chains, and every control's real range, units and options. Controls are measured " + + "the first time a device is described and cached (with a scan history); pass track/device_index to " + + "measure a device already in the project (its values are restored), or just a name to measure it on " + + "a temporary track. Measuring is skipped while the transport plays.", + TRACK_PROPS + """ + , + "name": {"type": "string", "description": "Bitwig device name, plugin name or CLAP id"}, + "device_index": {"type": "integer", "minimum": 0}, + "chain": {"type": "string"}, + "chain_device_index": {"type": "integer", "minimum": 0}, + "scan": {"type": "boolean", "description": "Measure controls if not cached (default true)"}, + "rescan": {"type": "boolean", "description": "Measure again even if cached"}""", + null, f, log, + a -> { + Integer track = a.integer("track_index") != null || a.string("track_name") != null ? a.track() : null; + return c.describe(a.string("name"), track, a.integer("device_index"), a.string("chain"), + a.integer("chain_device_index"), !Boolean.FALSE.equals(a.bool("scan")), Boolean.TRUE.equals(a.bool("rescan"))); + })); + + specs.add(tool("fetch_device_docs", + "Fetch Bitwig's own description of every built-in device from Bitwig's online user guide into the local " + + "catalog, so describe_device can include it (field bitwig_docs). Run once, and again after a Bitwig " + + "update; needs internet access.", + "", null, f, log, a -> c.fetchDocs())); + + specs.add(tool("catalog_status", + "Device catalog coverage and freshness: entries and scans per kind, the most recent scans with what " + + "changed, scans that failed, and scans made on an older Bitwig version.", + "", null, f, log, a -> c.status())); + + return specs; + } + + /** Project-wide, transport, track-order and clip-management tools. */ + public static List projectSpecifications( + BitwigEditFacade f, BitwigSessionFacade s, BitwigProjectFacade p, StructuredLogger log) { + List specs = new ArrayList<>(); + + specs.add(tool("project_state", "Whether any track is soloed, muted or armed, and whether the project has unsaved changes.", + "", null, f, log, a -> p.projectState())); + + specs.add(tool("reset_project", + "Clear every solo, mute and/or arm in the project. A forgotten solo silences all other tracks.", + """ + "unsolo": {"type": "boolean"}, + "unmute": {"type": "boolean"}, + "unarm": {"type": "boolean"}""", + null, f, log, a -> p.resetProject(Boolean.TRUE.equals(a.bool("unsolo")), Boolean.TRUE.equals(a.bool("unmute")), + Boolean.TRUE.equals(a.bool("unarm"))))); + + specs.add(tool("create_scene", "Add a new empty scene.", "", null, f, log, a -> p.createScene())); + + specs.add(tool("set_groove", "Set the global groove. Values are normalized 0-1; the response shows Bitwig's displayed values.", + """ + "enabled": {"type": "boolean"}, + "shuffle_amount": {"type": "number", "minimum": 0, "maximum": 1}, + "shuffle_rate": {"type": "number", "minimum": 0, "maximum": 1}, + "accent_amount": {"type": "number", "minimum": 0, "maximum": 1}, + "accent_rate": {"type": "number", "minimum": 0, "maximum": 1}, + "accent_phase": {"type": "number", "minimum": 0, "maximum": 1}""", + null, f, log, a -> p.setGroove(a.bool("enabled"), a.number("shuffle_amount"), a.number("shuffle_rate"), + a.number("accent_amount"), a.number("accent_rate"), a.number("accent_phase")))); + + specs.add(tool("set_transport_options", + "Transport settings beyond play/stop: punch in/out, metronome volume (0-1), pre-roll, automation write mode, " + + "launcher overdub, default launch quantization, post-recording action; or run a command " + + "(rewind, fast_forward, tap_tempo, continue, record). Enum values are Bitwig's ids; the response shows the current ones.", + """ + "punch_in": {"type": "boolean"}, + "punch_out": {"type": "boolean"}, + "metronome_volume": {"type": "number", "minimum": 0, "maximum": 1}, + "pre_roll": {"type": "string"}, + "automation_write_mode": {"type": "string"}, + "launcher_overdub": {"type": "boolean"}, + "default_launch_quantization": {"type": "string"}, + "post_recording_action": {"type": "string"}, + "command": {"type": "string", "enum": ["rewind", "fast_forward", "tap_tempo", "continue", "record"]}""", + null, f, log, a -> p.setTransportOptions(a.bool("punch_in"), a.bool("punch_out"), a.number("metronome_volume"), + a.string("pre_roll"), a.string("automation_write_mode"), a.bool("launcher_overdub"), + a.string("default_launch_quantization"), a.string("post_recording_action"), a.string("command")))); + + String clipMoveProps = """ + "from_track_index": {"type": "integer", "minimum": 0}, + "from_track_name": {"type": "string"}, + "from_slot": {"type": "integer", "minimum": 0}, + "to_track_index": {"type": "integer", "minimum": 0}, + "to_track_name": {"type": "string"}, + "to_slot": {"type": "integer", "minimum": 0}"""; + specs.add(tool("copy_clip", "Copy a launcher clip to an empty slot (any track).", clipMoveProps, "\"from_slot\", \"to_slot\"", f, log, + a -> p.copyOrMoveClip(a.track("from_track_index", "from_track_name"), a.requiredInt("from_slot"), + a.track("to_track_index", "to_track_name"), a.requiredInt("to_slot"), false))); + specs.add(tool("move_clip", "Move a launcher clip to an empty slot (any track).", clipMoveProps, "\"from_slot\", \"to_slot\"", f, log, + a -> p.copyOrMoveClip(a.track("from_track_index", "from_track_name"), a.requiredInt("from_slot"), + a.track("to_track_index", "to_track_name"), a.requiredInt("to_slot"), true))); + + String slotProps = TRACK_PROPS + ",\n\"slot_index\": {\"type\": \"integer\", \"minimum\": 0}"; + specs.add(tool("duplicate_clip", "Duplicate a launcher clip into the next free slot.", slotProps, "\"slot_index\"", f, log, + a -> p.duplicateClip(a.track(), a.requiredInt("slot_index")))); + specs.add(tool("record_into_slot", "Start recording into a launcher slot (the track must be armed).", slotProps, "\"slot_index\"", f, log, + a -> p.recordIntoSlot(a.track(), a.requiredInt("slot_index")))); + + String clipProps = slotProps + ",\n" + ARRANGER_PROP; + specs.add(tool("edit_notes", + "Change notes in a launcher clip (or the arranger's selected clip with arranger: true): all of them, or " + + "those matching a pitch and/or a start range in beats. velocity/pressure/chance/release_velocity/" + + "velocity_spread 0-1, duration in beats, pan and timbre -1..1, gain 0-1, transpose in semitones " + + "(micro-pitch, fractional allowed), muted, repeats (count, 0 = off) with repeat_curve and " + + "repeat_velocity_curve (-1..1) and repeat_velocity_end (-1..1). occurrence: play only on the first " + + "loop, not the first, when the previous note played, etc. ('off' clears it). recurrence: play on " + + "selected loop cycles, recurrence_length cycles (1-8, 0 = off) with recurrence_mask bits (bit 0 = " + + "first cycle).", + clipProps + """ + , + "release_velocity": {"type": "number", "minimum": 0, "maximum": 1}, + "velocity_spread": {"type": "number", "minimum": 0, "maximum": 1}, + "occurrence": {"type": "string", "enum": ["off", "always", "first", "not_first", "prev", "not_prev", "prev_channel", "not_prev_channel"]}, + "recurrence_length": {"type": "integer", "minimum": 0, "maximum": 8}, + "recurrence_mask": {"type": "integer", "minimum": 0, "maximum": 255}, + "repeat_curve": {"type": "number", "minimum": -1, "maximum": 1}, + "repeat_velocity_end": {"type": "number", "minimum": -1, "maximum": 1}, + "repeat_velocity_curve": {"type": "number", "minimum": -1, "maximum": 1}, + "pitch": {"type": "integer", "minimum": 0, "maximum": 127}, + "from_beats": {"type": "number", "minimum": 0}, + "to_beats": {"type": "number", "minimum": 0}, + "velocity": {"type": "number", "minimum": 0, "maximum": 1}, + "duration": {"type": "number", "exclusiveMinimum": 0}, + "gain": {"type": "number", "minimum": 0, "maximum": 1}, + "pan": {"type": "number", "minimum": -1, "maximum": 1}, + "timbre": {"type": "number", "minimum": -1, "maximum": 1}, + "pressure": {"type": "number", "minimum": 0, "maximum": 1}, + "transpose": {"type": "number"}, + "chance": {"type": "number", "minimum": 0, "maximum": 1}, + "muted": {"type": "boolean"}, + "repeats": {"type": "integer", "minimum": 0}""", + null, f, log, + a -> s.editNotes(a.clipTrack(), a.integer("slot_index"), a.arranger(), + new BitwigSessionFacade.NoteFilter(a.integer("pitch"), a.number("from_beats"), a.number("to_beats")), + new BitwigSessionFacade.NoteChanges(a.number("velocity"), a.number("duration"), a.number("gain"), + a.number("pan"), a.number("timbre"), a.number("pressure"), a.number("transpose"), a.number("chance"), + a.bool("muted"), a.integer("repeats"), a.number("release_velocity"), a.number("velocity_spread"), + a.string("occurrence"), a.integer("recurrence_length"), a.integer("recurrence_mask"), + a.number("repeat_curve"), a.number("repeat_velocity_end"), a.number("repeat_velocity_curve"))))); + + specs.add(tool("transform_clip", + "Whole-clip operations on a launcher clip (or the arranger's selected clip with arranger: true): transpose " + + "(semitones), quantize (0-1 strength), duplicate its content, set launch quantization and launch mode " + + "(launcher only; Bitwig enum ids, the response shows the current ones).", + clipProps + """ + , + "transpose": {"type": "integer"}, + "quantize": {"type": "number", "minimum": 0, "maximum": 1}, + "duplicate_content": {"type": "boolean"}, + "launch_quantization": {"type": "string"}, + "launch_mode": {"type": "string"}""", + null, f, log, + a -> s.transformClip(a.clipTrack(), a.integer("slot_index"), a.arranger(), a.integer("transpose"), + a.number("quantize"), a.bool("duplicate_content"), a.string("launch_quantization"), a.string("launch_mode")))); + + specs.add(tool("write_arranger_notes", + "Write notes into the clip selected in Bitwig's arranger (the API cannot place or select arranger clips; " + + "the user selects one). Same note format as write_notes.", + """ + "clear_first": {"type": "boolean"}, + "notes": {"type": "array", "items": {"type": "object", "properties": { + "start": {"type": "number", "minimum": 0}, + "pitch": {"type": "integer", "minimum": 0, "maximum": 127}, + "velocity": {"type": "integer", "minimum": 1, "maximum": 127}, + "duration": {"type": "number", "exclusiveMinimum": 0}}, + "required": ["start", "pitch", "duration"]}}""", + "\"notes\"", f, log, + a -> { + List notes = new ArrayList<>(); + for (Map n : a.list("notes")) { + Args na = new Args(n, f, "write_arranger_notes"); + Integer vel = na.integer("velocity"); + notes.add(new BitwigEditFacade.Note(na.number("start"), na.requiredInt("pitch"), vel == null ? 100 : vel, + na.number("duration"))); + } + return s.writeArrangerNotes(notes, Boolean.TRUE.equals(a.bool("clear_first"))); + })); + + specs.add(tool("set_scene", "Rename and/or color a scene.", + """ + "scene_index": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}, + "color": {"type": "string", "description": "#RRGGBB"}""", + "\"scene_index\"", f, log, + a -> s.setScene(a.requiredInt("scene_index"), a.string("name"), a.string("color")))); + String sceneProp = "\"scene_index\": {\"type\": \"integer\", \"minimum\": 0}"; + specs.add(tool("delete_scene", "Delete a scene and its clips.", sceneProp, "\"scene_index\"", f, log, + a -> s.deleteOrDuplicateScene(a.requiredInt("scene_index"), true))); + specs.add(tool("duplicate_scene", "Duplicate a scene and its clips; the copy goes below it.", sceneProp, + "\"scene_index\"", f, log, + a -> s.deleteOrDuplicateScene(a.requiredInt("scene_index"), false))); + String sceneMoveProps = """ + "from_scene": {"type": "integer", "minimum": 0}, + "to_scene": {"type": "integer", "minimum": 0}"""; + specs.add(tool("copy_scene", "Copy every clip of a scene onto another scene's row.", sceneMoveProps, + "\"from_scene\", \"to_scene\"", f, log, + a -> s.copyOrMoveScene(a.requiredInt("from_scene"), a.requiredInt("to_scene"), false))); + specs.add(tool("move_scene", "Move every clip of a scene onto another scene's row.", sceneMoveProps, + "\"from_scene\", \"to_scene\"", f, log, + a -> s.copyOrMoveScene(a.requiredInt("from_scene"), a.requiredInt("to_scene"), true))); + specs.add(tool("set_cue", "Set the cue (headphone) bus volume and its cue/master mix, both 0-1.", + """ + "volume": {"type": "number", "minimum": 0, "maximum": 1}, + "mix": {"type": "number", "minimum": 0, "maximum": 1}""", + null, f, log, a -> p.setCue(a.number("volume"), a.number("mix")))); + + return specs; + } + + /** Drum pads, layer mixers, presets and the browser, backed by {@link BitwigContainerFacade}. */ + public static List containerSpecifications( + BitwigEditFacade f, BitwigContainerFacade c, StructuredLogger log) { + List specs = new ArrayList<>(); + String mixProps = """ + "name": {"type": "string"}, + "volume": {"type": "number", "minimum": 0, "maximum": 1}, + "volume_db": {"type": "number", "maximum": 6}, + "pan": {"type": "number", "minimum": -1, "maximum": 1}, + "mute": {"type": "boolean"}, + "solo": {"type": "boolean"}, + "activated": {"type": "boolean"}"""; + String padProp = "\"pad\": {\"type\": \"integer\", \"minimum\": 0, \"maximum\": 127, " + + "\"description\": \"MIDI key of the pad (36 = C1, the usual kick)\"}"; + + specs.add(tool("list_drum_pads", + "The pads of a Drum Machine by MIDI key: name, volume, pan, mute, solo and the devices on each pad. " + + "Empty pads are left out unless include_empty.", + DEVICE_PROPS + ",\n\"include_empty\": {\"type\": \"boolean\"}", "\"device_index\"", f, log, + a -> c.listDrumPads(a.track(), a.requiredInt("device_index"), Boolean.TRUE.equals(a.bool("include_empty"))))); + + specs.add(tool("set_drum_pad", "Rename a Drum Machine pad or change its volume, pan, mute, solo or activation.", + DEVICE_PROPS + ",\n" + padProp + ",\n" + mixProps, "\"device_index\", \"pad\"", f, log, + a -> c.setDrumPad(a.track(), a.requiredInt("device_index"), a.requiredInt("pad"), a.string("name"), + a.number("volume"), a.number("volume_db"), a.number("pan"), a.bool("mute"), a.bool("solo"), + a.bool("activated")))); + + specs.add(tool("insert_into_drum_pad", + "Add a device to a Drum Machine pad (after what is there): a Bitwig device, a plugin, a preset file, " + + "or a sample file (Bitwig creates a Sampler for it).", + DEVICE_PROPS + ",\n" + padProp + """ + , + "bitwig_device": {"type": "string"}, + "clap_id": {"type": "string"}, + "vst3_id": {"type": "string"}, + "file": {"type": "string", "description": "Absolute path of a sample or preset"}""", + "\"device_index\", \"pad\"", f, log, + a -> c.insertIntoDrumPad(a.track(), a.requiredInt("device_index"), a.requiredInt("pad"), + a.string("bitwig_device"), a.string("clap_id"), a.string("vst3_id"), a.string("file")))); + + specs.add(tool("clear_drum_pads", "Unmute and/or unsolo every pad of a Drum Machine.", + DEVICE_PROPS + """ + , + "mutes": {"type": "boolean"}, + "solos": {"type": "boolean"}""", + "\"device_index\"", f, log, + a -> c.clearDrumPads(a.track(), a.requiredInt("device_index"), Boolean.TRUE.equals(a.bool("mutes")), + Boolean.TRUE.equals(a.bool("solos"))))); + + specs.add(tool("list_layers", + "The layers of a layer device (Instrument Layer, FX Layer, ...) with their mixer settings and devices. " + + "Devices inside a layer are edited with chain 'layer:N' on the device tools.", + DEVICE_PROPS, "\"device_index\"", f, log, + a -> c.listLayers(a.track(), a.requiredInt("device_index")))); + + specs.add(tool("set_layer", "Rename a layer or change its volume, pan, mute, solo or activation.", + DEVICE_PROPS + ",\n\"layer\": {\"type\": \"integer\", \"minimum\": 0},\n" + mixProps, + "\"device_index\", \"layer\"", f, log, + a -> c.setLayer(a.track(), a.requiredInt("device_index"), a.requiredInt("layer"), a.string("name"), + a.number("volume"), a.number("volume_db"), a.number("pan"), a.bool("mute"), a.bool("solo"), + a.bool("activated")))); + + specs.add(tool("list_presets", + "Search Bitwig preset files on disk (installed packages and the user library). Every word of query must " + + "match the name, collection, device or category. Load one with insert_device or replace_device " + + "(preset_file). Packaged presets do not reveal their device; browser_open with mode 'replace' " + + "lists the presets of a device.", + """ + "query": {"type": "string"}, + "limit": {"type": "integer", "minimum": 1}, + "rescan": {"type": "boolean", "description": "Re-read the disk (after installing packages)"}""", + null, f, log, + a -> c.listPresets(a.string("query"), a.integer("limit"), Boolean.TRUE.equals(a.bool("rescan"))))); + + specs.add(tool("browser_open", + "Open Bitwig's browser to load something at a place: mode 'replace' (a device; Bitwig shows that " + + "device's presets), 'after'/'before' (a device), 'start'/'end' (the track's chain). It shows in " + + "Bitwig's window until browser_commit or browser_cancel.", + TRACK_PROPS + """ + , + "device_index": {"type": "integer", "minimum": 0}, + "mode": {"type": "string", "enum": ["replace", "after", "before", "start", "end"]}, + "content_type": {"type": "string", "description": "e.g. Devices, Presets, Samples (see content_types)"}""", + null, f, log, + a -> c.openBrowser(a.track(), a.integer("device_index"), a.string("mode"), a.string("content_type")))); + + specs.add(tool("browser_state", + "The open browser: content type, the selected item of each filter column, and a page of results " + + "(64 per page). list_column also returns every item of that column.", + """ + "list_column": {"type": "string", "enum": ["collection", "location", "device", "category", "tag", "device_type", "file_type", "creator"]}, + "results_page": {"type": "integer", "minimum": 0}""", + null, f, log, + a -> c.browserState(a.string("list_column"), a.integer("results_page") == null ? 0 : a.integer("results_page")))); + + specs.add(tool("browser_filter", + "Narrow the open browser: content_type, and filters as {column: item}, e.g. {\"category\": \"Bass\", " + + "\"creator\": \"Bitwig\"}. Columns: collection, location, device, category, tag, device_type, " + + "file_type, creator. An 'Any ...' item clears a column.", + """ + "content_type": {"type": "string"}, + "filters": {"type": "object", "additionalProperties": {"type": "string"}}""", + null, f, log, + a -> c.filterBrowser(a.string("content_type"), a.stringMap("filters")))); + + specs.add(tool("browser_commit", "Load a result of the open browser, by name or by index, and close it.", + """ + "result": {"type": "string"}, + "result_index": {"type": "integer", "minimum": 0}""", + null, f, log, + a -> c.commitBrowser(a.string("result"), a.integer("result_index")))); + + specs.add(tool("browser_cancel", "Close the browser without loading anything.", "", null, f, log, + a -> c.cancelBrowser())); + + return specs; + } + + /** Listening and auditioning tools backed by {@link BitwigSessionFacade}. */ + public static List sessionSpecifications( + BitwigEditFacade f, BitwigSessionFacade s, StructuredLogger log) { + List specs = new ArrayList<>(); + + specs.add(tool("set_engine_active", + "Activate or deactivate Bitwig's audio engine for the current project. Only one open project has the " + + "engine; without it nothing plays, meters or auditions (status shows audio_engine_active).", + "\"active\": {\"type\": \"boolean\"}", "\"active\"", f, log, + a -> s.setEngineActive(f.application(), a.requiredBool("active")))); + + specs.add(tool("get_levels", + "Current peak and RMS meter readings for every track and the master (0-1 of Bitwig's meter scale, " + + "not dB). Only meaningful while audio is playing.", + "", null, f, log, a -> s.getLevels())); + + specs.add(tool("list_cue_markers", "List arranger cue markers with their positions in beats.", + "", null, f, log, a -> s.listCueMarkers())); + + specs.add(tool("go_to_cue_marker", "Jump playback to a cue marker by index or name.", + """ + "index": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}, + "quantized": {"type": "boolean", "description": "Wait for the launch quantization (default false)"}""", + null, f, log, + a -> s.goToCueMarker(a.integer("index"), a.string("name"), Boolean.TRUE.equals(a.bool("quantized"))))); + + specs.add(tool("set_arrangement", "Set the arranger loop range (in beats) and/or the time signature, e.g. '6/8'.", + """ + "loop_start_beats": {"type": "number", "minimum": 0}, + "loop_length_beats": {"type": "number", "exclusiveMinimum": 0}, + "time_signature": {"type": "string"}""", + null, f, log, + a -> s.setArrangement(a.number("loop_start_beats"), a.number("loop_length_beats"), a.string("time_signature")))); + + specs.add(tool("rename_scene", "Rename a scene.", + """ + "scene_index": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}""", + "\"scene_index\", \"name\"", f, log, + a -> s.renameScene(a.requiredInt("scene_index"), a.requiredString("name")))); + + specs.add(tool("stop_track", "Stop a track's launcher clip, or return the track to arrangement playback.", + TRACK_PROPS + ",\n\"return_to_arrangement\": {\"type\": \"boolean\"}", null, f, log, + a -> s.stopTrack(a.track(), Boolean.TRUE.equals(a.bool("return_to_arrangement"))))); + + specs.add(tool("play_notes", + "Play notes into a track's instrument right now, without a clip, to audition a sound or chord. " + + "Times are in milliseconds from the call.", + TRACK_PROPS + """ + , + "notes": {"type": "array", "items": {"type": "object", "properties": { + "pitch": {"type": "integer", "minimum": 0, "maximum": 127}, + "velocity": {"type": "integer", "minimum": 1, "maximum": 127}, + "start_ms": {"type": "integer", "minimum": 0}, + "duration_ms": {"type": "integer", "minimum": 1}}, + "required": ["pitch", "duration_ms"]}}""", + "\"notes\"", f, log, + a -> { + List notes = new ArrayList<>(); + for (Map n : a.list("notes")) { + Args na = new Args(n, f, "play_notes"); + Integer vel = na.integer("velocity"); + Integer start = na.integer("start_ms"); + notes.add(new BitwigSessionFacade.AuditionNote(na.requiredInt("pitch"), vel == null ? 100 : vel, + start == null ? 0 : start, na.requiredInt("duration_ms"))); + } + return s.playNotes(a.track(), notes); + })); + + specs.add(tool("get_playing_notes", + "Which notes are sounding right now on each track (from clips, the user's input or play_notes).", + "", null, f, log, a -> s.playingNotes())); + + specs.add(tool("send_midi", + "Send a MIDI message into a track's instrument: cc (number, value 0-127), pitch_bend (value -8192..8191, " + + "0 = center), program (number), aftertouch (value), or raw (status 0x80-0xEF, number, value). " + + "channel 0-15, default 0. Use play_notes for notes.", + TRACK_PROPS + """ + , + "type": {"type": "string", "enum": ["cc", "pitch_bend", "program", "aftertouch", "raw"]}, + "channel": {"type": "integer", "minimum": 0, "maximum": 15}, + "number": {"type": "integer", "minimum": 0, "maximum": 127}, + "value": {"type": "integer"}, + "status": {"type": "integer"}""", + "\"type\"", f, log, + a -> s.sendMidi(a.track(), a.requiredString("type"), a.integer("channel") == null ? 0 : a.integer("channel"), + a.integer("number"), a.integer("value"), a.integer("status")))); + + specs.add(tool("show", + "Bring something into view in Bitwig's window for the user: a panel layout (ARRANGE, MIX, EDIT), a track, " + + "a launcher clip in the editor (slot_index), or a device (device_index). Changes the user's selection.", + TRACK_PROPS + """ + , + "panel_layout": {"type": "string", "enum": ["ARRANGE", "MIX", "EDIT"]}, + "slot_index": {"type": "integer", "minimum": 0}, + "device_index": {"type": "integer", "minimum": 0}""", + null, f, log, + a -> f.show(a.string("panel_layout"), + a.integer("track_index") != null || a.string("track_name") != null ? a.track() : null, + a.integer("slot_index"), a.integer("device_index")))); + + specs.add(tool("read_notes", + "Read the MIDI notes of a launcher clip, or of the arranger's selected clip with arranger: true (beats from " + + "the clip start), with every expression and condition that is not at its default, and the clip settings.", + TRACK_PROPS + ",\n\"slot_index\": {\"type\": \"integer\", \"minimum\": 0},\n" + ARRANGER_PROP, null, f, log, + a -> s.readNotes(a.clipTrack(), a.integer("slot_index"), a.arranger()))); + + specs.add(tool("set_clip", + "Clip settings for a launcher clip (or the arranger's selected clip with arranger: true): name, loop " + + "on/off, loop start and length, play start and stop (beats), shuffle on/off, accent (0-1, 0.5 = none), color.", + TRACK_PROPS + ",\n" + ARRANGER_PROP + """ + , + "slot_index": {"type": "integer", "minimum": 0}, + "name": {"type": "string"}, + "loop_enabled": {"type": "boolean"}, + "loop_start_beats": {"type": "number", "minimum": 0}, + "loop_length_beats": {"type": "number", "exclusiveMinimum": 0}, + "play_start_beats": {"type": "number", "minimum": 0}, + "play_stop_beats": {"type": "number", "minimum": 0}, + "shuffle": {"type": "boolean"}, + "accent": {"type": "number", "minimum": 0, "maximum": 1}, + "color": {"type": "string", "description": "#RRGGBB"}""", + null, f, log, + a -> s.setClip(a.clipTrack(), a.integer("slot_index"), a.arranger(), new BitwigSessionFacade.ClipSettings( + a.string("name"), a.number("loop_start_beats"), a.number("loop_length_beats"), a.bool("loop_enabled"), + a.number("play_start_beats"), a.number("play_stop_beats"), a.bool("shuffle"), a.number("accent"), + a.string("color"))))); + + return specs; + } +} diff --git a/src/main/resources/bitwig-devices.txt b/src/main/resources/bitwig-devices.txt new file mode 100644 index 00000000..484b4330 --- /dev/null +++ b/src/main/resources/bitwig-devices.txt @@ -0,0 +1,154 @@ +# Bitwig built-in devices (Name$uuid), generated from Bitwig Studio's Library/device-settings by +# scripts/device_catalog.py update-resource. The extension also reads that library at startup. +Amp$41be8f3a-6d24-4442-9508-8548dbe62d47 +Arpeggiator$4d407a2b-c91b-4e4c-9a89-c53c19fe6251 +Audio MOD$01c7c48f-40cd-40cd-8a9a-1f258f1cc7d5 +Audio Receiver$46b3e40a-629c-42c2-9e14-a1ccbcaa903b +Bend$6aec6e78-9c1e-4c0b-8a88-0c2c37890a1d +Bit-8$43875255-6f1f-4d54-a5ad-c45bff793477 +Blur$72a3018d-788b-472c-b1d7-16419d00f4c6 +Chain$c86d21fb-d544-4daf-a1bf-57de22aa320c +Channel Filter$c5a1bb2d-a589-4fda-b3cf-911cfd6297be +Channel Map$0f003fa3-adcc-4684-81f7-f0e11c09c5b4 +Chorus$d275f9a6-0e4a-409c-9dc4-d74af90bc7ae +Chorus+$1b8f2226-c432-4a0a-9830-69bc76d1a276 +Comb$20e18780-8438-48d3-b234-40dcbaa947b8 +Compressor$2b1b4787-8d74-4138-877b-9197209eef0f +Compressor+$42b32cd2-6275-4ff1-970f-4fac71d15ad9 +Convolution$528f7939-87c0-4997-8e71-6331d2eee388 +DC Offset$ee445061-a0ee-4322-991a-b60212db04ed +De-Esser$8750db61-e9d3-4d0e-a610-e734006a64dc +Delay+$f2baa2a8-36c5-4a79-b1d9-a4e461c45ee9 +Delay-1$2a7a7328-3f7a-4afb-95eb-5230c298bb90 +Delay-2$71539d5d-1c7a-4dac-8f74-29e23b89b599 +Delay-4$f95a0e18-5a8b-4f53-93ad-8be73fd668bd +Distortion$b5b2b08e-730e-4192-be71-f572ceb5069b +Dribble$d98f7ce5-564e-4b95-926a-4e7b50a251c6 +Drum Machine$8ea97e45-0255-40fd-bc7e-94419741e9d1 +Dual Pan$c94820f8-3779-438b-a85b-868e57b746cc +Dynamics$22e785a2-a187-41e9-a0f2-66343694014c +Echo$43c102c9-ce32-4dd8-b207-f0831733b17b +EQ+$e4815188-ba6f-4d14-bcfc-2dcb8f778ccb +EQ-2$01af068e-1e49-4777-a6e6-7f1dc679227a +EQ-5$227e2e3c-75d5-46f3-960d-8fb5529fe29f +EQ-DJ$3cc1b71a-e22a-42cf-89f0-316475368fb3 +Filter$4ccfc70e-59bd-4e97-a8a7-d8cdce88bf42 +Filter+$6d621c1c-ab64-43b4-aea3-dad37e6f649c +Flanger$8393c436-b11b-4fee-85dd-b2ef0a2ed380 +Flanger+$a99f8c3c-7813-4e6b-a18a-302c74286efc +FM-4$7a0a94df-3aa4-4bb5-8e24-2511999871ad +Focus$42208fc5-02fd-42b4-9681-a8fadb46575f +Freq Shifter$7ec87fdf-0bf8-42e7-b54b-5d8b68e330b1 +Freq Shifter+$eb28831d-2478-4918-bd51-bcc1ff4c7eed +Freq Split$3f3c3200-e6aa-4578-8e06-f573ed65206e +FX Grid$d641f61b-d4db-4006-930e-cdd7aeb3e9d7 +FX Layer$a0913b7f-096b-4ac9-bddd-33c775314b42 +FX Selector$956e396b-07c5-4430-a58d-8dcfc316522a +Gate$556300ac-3a6e-4423-966a-5d5dde459a1b +Harmonic Split$c90b6d52-898b-4dad-aa58-2c58add7c94f +Harmonize$ff299d28-d822-4686-ac0a-03c0ae69b32d +Humanize$f7b6f2a6-bfca-41ec-8646-b68e0f4cf12b +HW Clock Out$a2b59797-2b25-4860-862d-6ab72393b4ca +HW CV Instrument$c511bc17-9ebf-43de-a20c-4a9e40028fdf +HW CV Out$d0e71e2d-d491-4cce-a227-fbe118cc4e52 +HW FX$29b93a99-eb3a-4b19-8c12-8b4391f5a1ea +HW Instrument$6a27aef7-bba5-4b0d-af98-7c192f84fbc2 +Instrument Layer$5024be2e-65d6-4d40-bbfe-8b2ea993c445 +Instrument Selector$9588fbcf-721a-438b-8555-97e4231f7d2c +Key Filter$f14bacde-084c-4f14-8bdf-d8c4fda8b368 +Key Filter+$ad13588c-fe39-4bd0-8615-d56e495eb05d +Ladder$abfbbd63-8801-4bdb-a1ad-4b197f4d41e0 +Latch$93c9d566-4cc9-4895-bf5b-475cab44eba9 +LFO MOD$613dd120-9f55-4d24-97ac-f7902ffa7ce7 +Loud Split$6e75a854-ceab-475b-94c5-75188ee998b8 +Micro-pitch$4ac40334-99cc-43a3-b693-f3dc63211f0c +Mid-Side Split$a6c9b12f-45a5-43e3-b100-b74ecf77367b +MIDI CC$a0b8f27a-128e-4f72-b9fc-a277060b87ee +MIDI Program Change$429c7dcb-6863-48bc-becc-508463841e3b +MIDI Song Select$754fdba1-2c16-494c-a47c-16f7a7ad9363 +Multi-note$0a015261-7546-4f6d-9197-098a26ff2c20 +Multiband FX-2$214857d6-b468-4257-9bc9-92f017af1782 +Multiband FX-3$f97699d1-3b8e-4363-8ede-4994e276cc97 +Note Delay$9f3cc825-3284-4c5a-b51f-01219de13b7c +Note Filter$ef7559c8-49ae-4657-95be-11abb896c969 +Note FX Layer$96456481-4c52-423a-8485-4604b15d0183 +Note FX Selector$8fd471db-15df-44c6-b497-4bb851d4fd46 +Note Grid$264d6f4e-5067-46c9-a4fa-a75a295d9e01 +Note Length$4c396eb6-953d-4de0-afaa-63276fc1150b +Note MOD$1179be46-4d43-4a26-bb5f-430bc3fef9ba +Note Receiver$c6153773-ed96-4cca-a767-5cf3d5dceacb +Note Repeats$a68e0f1b-bcc6-45c2-b09e-8c8771f83e50 +Note Transpose$0815cd9e-3a31-4429-a268-dabd952a3b68 +Organ$f2dcfe9a-7b66-4c84-984a-b25685a1c21a +Oscilloscope$ffe670a2-09aa-4c9b-8822-5161a9cca686 +Over$41b34699-8e5d-4534-a429-a67d488ba6ac +Peak Limiter$8da7251e-2578-4bcc-b3c4-8f4ec2e115d0 +Phase-4$252723bf-68a6-4ee6-81f8-95ba4d0fb467 +Phaser$fc87ae07-1624-449f-8dae-2db5d93e1aa9 +Phaser+$fd7a9e6c-6992-40c2-be3b-ac8ed48553e9 +Pitch Shifter$384fe469-6023-4f69-9560-e0c2eec2da49 +Poly Grid$a33bba66-8cd4-4f89-aee5-68bf67f70a54 +Polymer$8f58138b-03aa-4e9d-83bd-a038c99a4ed5 +Polysynth$a9ffacb5-33e9-4fc7-8621-b1af31e410ef +Quantize$1c116b76-2b07-4b16-bf2a-ed5f0bdcc661 +Randomize$dc06a9a5-b0b7-41f1-af45-a3a1d2173fb8 +Replacer$c8ed6372-d24f-47e0-9e9b-5b2a37949c45 +Resonator Bank$b64070ae-5a59-4640-bb6a-194619bc12d8 +Reverb$5a1cb339-1c4a-4cc7-9cae-bd7a2058153d +Ricochet$0645879b-efea-4bbe-8e41-9a176247b808 +Ring-Mod$374feaeb-c785-4243-9d08-3f9099b4c0cb +Rotary$8fc25e70-b92b-4096-8270-42e492df501a +Sampler$468bc14b-b2e7-45a1-9666-e83117fe404e +Saturator$93d11348-86ae-4ead-9fe7-84ac03b9369c +Sculpt$8d9d63db-9991-4e46-8b4c-77755d1fcaab +Spectrum$fcd9aa65-ebbb-4337-a97e-69929322ef47 +Step MOD$18a37a4d-8613-442d-a6eb-931002ba9a36 +Stepwise$c278d0d9-3e48-432a-a3b8-b33eff9533d2 +Stereo Split$96196ffe-658f-46c4-84ba-153799be3657 +Strum$109d747c-ec75-4255-b495-7adba6ea66b6 +Sweep$ab52804f-1169-4657-b8c8-8db5532cf717 +Test Tone$20b72dbc-0fe1-47c5-867c-f0ab1510f723 +Tilt$061dcec6-543f-46f6-b679-f092eeefdbe4 +Time Shift$861bb5b0-5cd6-4066-9681-1cc561cb898f +Tool$e67b9c56-838d-4fba-8e3e-ae4e02cccbcb +Transient Control$71e6dbd8-a117-4ff0-85e8-5650f5a76d98 +Transient Split$7c3c7bb2-625d-4915-ae95-943ee9aa807d +Transpose Map$284a1949-29d5-4dd4-8315-86cef92fd2cd +Treemonster$e45e00d2-85a0-4c05-8321-819694befa09 +Tremolo$f3b90fff-402b-4187-9aab-620f441577b9 +Tuner$e0ec7fdd-8b04-468b-8ebe-d320495957dc +v0 Cymbal$7b21c41e-67c3-4dd0-aa64-4fbb03d95cdb +v0 Hat$212c6aa0-04b6-49b7-a77f-c1fcee5d33a1 +v0 Kick$8415c7af-1379-4730-97bf-16380f96d0fe +v0 Snare$446c58e3-ee39-4a22-b1e1-a62c614f98d4 +v0 Tom$3c6105ad-176e-4403-993b-3eedefdf6dda +v0 Zap Kick$eef2e851-925b-4e86-81c6-67463e17c5f7 +v1 Clap$89eba41d-46d3-4506-8ce6-ba9fe3e3bee4 +v1 Cowbell$dd594db1-a908-453f-a1b9-0a1b6c4c3b32 +v1 Hat$742e4a89-df78-4ca5-b6b0-ca78889d5953 +v1 Kick$c6d5de18-a6f1-4daa-90a9-d9254527601a +v1 Snare$db22eb41-c8a0-4055-b617-637614dfa185 +v1 Tom$b5c7c298-e6af-42b3-8f14-26e25bb72d48 +v8 Clap$b13d3937-6002-4e88-8e50-e99119708072 +v8 Claves$1b709991-7d7d-45d1-aec8-847c01611bfb +v8 Cowbell$f3e8fa57-dd7a-4d94-91dd-1376c1c8304a +v8 Cymbal$0af9f363-ff81-4e72-b2b2-31c8c9682e28 +v8 Hat$85d9c654-088f-4a8a-bbfc-e98af9eafb7b +v8 Kick$10fba33b-8e65-4eea-a5cf-312986178240 +v8 Maracas$03ec3a24-b3c9-4ba4-b6dc-855178d60de7 +v8 Rimshot$9412c72e-acec-4345-acd1-ff5dc20bc2a4 +v8 Snare$97938f59-c3d2-4b2c-8640-21c2fd2cc516 +v8 Tom$e1be73d9-ba43-4011-91b7-2178bc4af5ea +v9 Clap$3df67ed2-4d70-4a86-a966-14762e2aeea4 +v9 Crash$84bd7819-2007-46e0-b930-b4dacff1974a +v9 Hat Closed$5c147bc8-7b62-408b-b057-c4023c4e1adb +v9 Hat Open$94fc934e-a4ba-44f1-aaaa-ae30920fab17 +v9 Kick$32a4c607-039a-4998-be9c-578468f25454 +v9 Ride$38f52e07-2339-491e-9dd4-8bf6a95c2dae +v9 Rimshot$f88c7dda-c8cd-456f-8bdf-ac25fa5bfea1 +v9 Snare$90600c24-04c5-412e-b978-6d3cef1522da +v9 Tom$60f69854-fda1-4538-9ff1-c1553ea25224 +Velocity Curve$066d0065-99a4-47da-b0f7-9468ef69c1cf +Vocoder$a0cb2ec0-2464-461c-8165-296f98905539 +XY FX$51169152-c144-4a38-95ba-1390fb579a1f +XY Instrument$bab3f04d-d3b6-4dfa-86f9-506e0b091ca8 diff --git a/src/test/java/io/github/fabb/wigai/bitwig/BitwigApiFacadeTest.java b/src/test/java/io/github/fabb/wigai/bitwig/BitwigApiFacadeTest.java index ef02ab5a..047dab9f 100644 --- a/src/test/java/io/github/fabb/wigai/bitwig/BitwigApiFacadeTest.java +++ b/src/test/java/io/github/fabb/wigai/bitwig/BitwigApiFacadeTest.java @@ -98,7 +98,7 @@ void setUp() { when(mockMasterTrack.createCursorRemoteControlsPage(8)).thenReturn(mockProjectParameterBank); // Setup TrackBank mocks (new for clip launching) - use smaller sizes for testing - when(mockHost.createTrackBank(128, 0, 128)).thenReturn(mockTrackBank); + when(mockHost.createTrackBank(128, 8, 128)).thenReturn(mockTrackBank); when(mockTrackBank.getSizeOfBank()).thenReturn(8); // Reduced from 128 to 8 for testing when(mockTrackBank.getItemAt(anyInt())).thenReturn(mockTrack); when(mockTrack.clipLauncherSlotBank()).thenReturn(mockClipLauncherSlotBank); diff --git a/src/test/java/io/github/fabb/wigai/bitwig/BitwigContainerFacadeTest.java b/src/test/java/io/github/fabb/wigai/bitwig/BitwigContainerFacadeTest.java new file mode 100644 index 00000000..2aae037d --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/BitwigContainerFacadeTest.java @@ -0,0 +1,75 @@ +package io.github.fabb.wigai.bitwig; + +import io.github.fabb.wigai.common.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class BitwigContainerFacadeTest { + + private static byte[] preset(String... kvs) throws Exception { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + b.write("BtWg0003meta".getBytes()); + for (int i = 0; i < kvs.length; i += 2) { + b.write(new byte[]{0, 0, 0, (byte) kvs[i].length()}); + b.write(kvs[i].getBytes()); + b.write(new byte[]{8, 0, 0, 0, (byte) kvs[i + 1].length()}); + b.write(kvs[i + 1].getBytes()); + b.write(new byte[]{0, 0, 0, 1}); + } + return b.toByteArray(); + } + + @SuppressWarnings("unchecked") + private static List> presets(Map result) { + return (List>) result.get("presets"); + } + + @Test + void scansPresetsWithReadableAndEncryptedHeaders(@TempDir Path dir) throws Exception { + Path pkg = Files.createDirectories(dir.resolve("packages/5.0/Bitwig/Bass and Guitar")); + Files.write(pkg.resolve("Acoustic Bass Long.bwpreset"), new byte[]{1, 2, 3}); + Path user = Files.createDirectories(dir.resolve("user/Presets")); + Files.write(user.resolve("Alien Choir.bwpreset"), preset("device_name", "Vocoder", "preset_category", "Vocal")); + Files.write(user.resolve("notes.txt"), new byte[]{0}); + + List> index = BitwigContainerFacade.scanPresets( + List.of(dir.resolve("packages"), dir.resolve("user"), dir.resolve("missing")), mock(Logger.class)); + + assertEquals(2, index.size()); + Map bass = index.get(0); + assertEquals("Acoustic Bass Long", bass.get("name")); + assertEquals("5.0/Bitwig/Bass and Guitar", bass.get("collection")); + assertFalse(bass.containsKey("device"), "encrypted presets have no readable device"); + Map choir = index.get(1); + assertEquals("Vocoder", choir.get("device")); + assertEquals("Vocal", choir.get("category")); + assertTrue(((String) choir.get("path")).endsWith("Alien Choir.bwpreset")); + } + + @Test + void searchMatchesEveryWordAcrossFields() { + List> index = List.of( + Map.of("name", "Acoustic Bass Long", "collection", "5.0/Bitwig/Bass and Guitar", "path", "a"), + Map.of("name", "Alien Choir", "collection", "Presets", "device", "Vocoder", "category", "Vocal", "path", "b"), + Map.of("name", "Deep Bass", "collection", "Presets", "device", "Polysynth", "path", "c")); + + assertEquals(2, BitwigContainerFacade.searchPresets(index, "bass", 10).get("matches")); + assertEquals("Deep Bass", presets(BitwigContainerFacade.searchPresets(index, "BASS polysynth", 10)).get(0).get("name")); + assertEquals("Alien Choir", presets(BitwigContainerFacade.searchPresets(index, "vocoder vocal", 10)).get(0).get("name")); + assertEquals(0, BitwigContainerFacade.searchPresets(index, "bass vocoder", 10).get("matches")); + + Map all = BitwigContainerFacade.searchPresets(index, null, 1); + assertEquals(3, all.get("indexed")); + assertEquals(3, all.get("matches")); + assertEquals(1, presets(all).size(), "limit caps the list but not the match count"); + } +} diff --git a/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeBehaviourTest.java b/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeBehaviourTest.java new file mode 100644 index 00000000..52ea4245 --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeBehaviourTest.java @@ -0,0 +1,294 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Behaviour tests for BitwigEditFacade against small fakes of the Bitwig objects it drives: + * a device with three remote-control pages, a fader with a dB display, and a launcher clip. + * The fakes model the live quirks that broke these tools: values written with set() are + * ignored (only setImmediately() applies) and displays are rounded. + */ +class BitwigEditFacadeBehaviourTest { + + private static final String[][] PAGES = { + {"Size", "Pre-delay", "Diffusion", "R. Time", "Early/Late", "Buildup", "St. Width", "Mix"}, + {"Low Cut", "High Cut", "Damping"}, + {"Gain"} + }; + + private Track track; + private Device device; + private ClipLauncherSlot slot0; + private PinnableCursorClip clip; + private Transport transport; + private BitwigEditFacade facade; + + private final AtomicInteger selectedPage = new AtomicInteger(0); + private final double[][] values = new double[PAGES.length][8]; + private final AtomicReference fader = new AtomicReference<>(0.5); + private final AtomicBoolean slotHasContent = new AtomicBoolean(false); + + /** A ranged value that ignores set() and applies setImmediately(), like Bitwig does for extensions. */ + private static SettableRangedValue rangedValue(java.util.function.DoubleSupplier read, + java.util.function.DoubleConsumer write) { + SettableRangedValue v = mock(SettableRangedValue.class); + when(v.get()).thenAnswer(i -> read.getAsDouble()); + doAnswer(i -> { + write.accept(i.getArgument(0)); + return null; + }).when(v).setImmediately(anyDouble()); + return v; + } + + private static StringValue stringValue(java.util.function.Supplier read) { + StringValue s = mock(StringValue.class); + when(s.get()).thenAnswer(i -> read.get()); + return s; + } + + /** Fader display: 0 dB at 0.7937, 60 dB of range per unit, rounded to 0.1 dB like Bitwig. */ + static String faderDisplay(double v) { + if (v <= 0) { + return "-Inf dB"; + } + double db = 60 * Math.log10(v / 0.7937) / Math.log10(2); + return String.format(java.util.Locale.ROOT, "%.1f dB", db); + } + + @BeforeEach + void setUp() { + ControllerHost host = mock(ControllerHost.class, RETURNS_DEEP_STUBS); + Application application = mock(Application.class); + when(application.panelLayout()).thenReturn(mock(StringValue.class)); + transport = mock(Transport.class, RETURNS_DEEP_STUBS); + TrackBank trackBank = mock(TrackBank.class); + track = mock(Track.class, RETURNS_DEEP_STUBS); + DeviceBank deviceBank = mock(DeviceBank.class); + device = mock(Device.class, RETURNS_DEEP_STUBS); + + when(trackBank.getSizeOfBank()).thenReturn(1); + when(trackBank.getItemAt(0)).thenReturn(track); + when(track.exists().get()).thenReturn(true); + when(track.name().get()).thenReturn("Vox"); + when(track.trackType().get()).thenReturn("Audio"); + when(deviceBank.getSizeOfBank()).thenReturn(1); + when(deviceBank.getItemAt(0)).thenReturn(device); + when(device.exists().get()).thenReturn(true); + when(device.name().get()).thenReturn("Reverb"); + + // fader + Parameter volume = mock(Parameter.class); + SettableRangedValue volumeValue = rangedValue(fader::get, fader::set); + when(volume.value()).thenReturn(volumeValue); + StringValue volumeDisplay = stringValue(() -> faderDisplay(fader.get())); + when(volume.displayedValue()).thenReturn(volumeDisplay); + when(track.volume()).thenReturn(volume); + + // clip slot + ClipLauncherSlotBank slots = mock(ClipLauncherSlotBank.class); + slot0 = mock(ClipLauncherSlot.class, RETURNS_DEEP_STUBS); + when(slots.getSizeOfBank()).thenReturn(4); + when(slots.getItemAt(anyInt())).thenReturn(slot0); + when(slot0.hasContent().get()).thenAnswer(i -> slotHasContent.get()); + doAnswer(i -> { + slotHasContent.set(true); + return null; + }).when(slot0).createEmptyClip(anyInt()); + when(track.clipLauncherSlotBank()).thenReturn(slots); + + // private edit cursor: track, device, remote-control page, launcher clip + CursorTrack editTrack = mock(CursorTrack.class, RETURNS_DEEP_STUBS); + PinnableCursorDevice editDevice = mock(PinnableCursorDevice.class, RETURNS_DEEP_STUBS); + CursorRemoteControlsPage page = mock(CursorRemoteControlsPage.class, RETURNS_DEEP_STUBS); + clip = mock(PinnableCursorClip.class, RETURNS_DEEP_STUBS); + when(host.createCursorTrack(anyString(), anyString(), anyInt(), anyInt(), anyBoolean())).thenReturn(editTrack); + when(editTrack.createCursorDevice(anyString(), anyString(), anyInt(), any())).thenReturn(editDevice); + when(editDevice.createCursorRemoteControlsPage(8)).thenReturn(page); + when(editTrack.createLauncherCursorClip(anyInt(), anyInt())).thenReturn(clip); + when(clip.exists().get()).thenReturn(true); + + when(editTrack.name().get()).thenReturn("Vox"); + when(editDevice.exists().get()).thenReturn(true); + when(editDevice.position().get()).thenReturn(0); + when(editDevice.name().get()).thenReturn("Reverb"); + + String[] pageNames = {"Main", "Tone", "Common"}; + when(page.pageNames().get()).thenReturn(pageNames); + SettableIntegerValue pageIndex = mock(SettableIntegerValue.class); + when(pageIndex.get()).thenAnswer(i -> selectedPage.get()); + doAnswer(i -> { + selectedPage.set(i.getArgument(0)); + return null; + }).when(pageIndex).set(anyInt()); + when(page.selectedPageIndex()).thenReturn(pageIndex); + when(page.getParameterCount()).thenReturn(8); + for (int i = 0; i < 8; i++) { + final int slot = i; + RemoteControl rc = mock(RemoteControl.class, RETURNS_DEEP_STUBS); + when(rc.exists().get()).thenAnswer(x -> slot < PAGES[selectedPage.get()].length); + when(rc.name().get()).thenAnswer(x -> slot < PAGES[selectedPage.get()].length ? PAGES[selectedPage.get()][slot] : ""); + SettableRangedValue v = rangedValue(() -> values[selectedPage.get()][slot], + d -> values[selectedPage.get()][slot] = d); + when(rc.value()).thenReturn(v); + StringValue display = stringValue(() -> String.format(java.util.Locale.ROOT, "%.1f %%", values[selectedPage.get()][slot] * 100)); + when(rc.displayedValue()).thenReturn(display); + when(page.getParameter(i)).thenReturn(rc); + } + + facade = new BitwigEditFacade(host, mock(Logger.class), application, transport, trackBank, + List.of(deviceBank), 0); + } + + @SuppressWarnings("unchecked") + private static List> list(Object o) { + return (List>) o; + } + + @Test + void readsEveryPage() throws Exception { + Map r = facade.getDeviceParameters(0, 0, null); + assertArrayEquals(new String[]{"Main", "Tone", "Common"}, (String[]) r.get("page_names")); + List> pages = list(r.get("pages")); + assertEquals(3, pages.size()); + assertEquals(8, list(pages.get(0).get("parameters")).size()); + assertEquals(3, list(pages.get(1).get("parameters")).size()); + assertEquals("Gain", list(pages.get(2).get("parameters")).get(0).get("name")); + } + + @Test + void readsOnePage() throws Exception { + Map r = facade.getDeviceParameters(0, 0, 1); + List> pages = list(r.get("pages")); + assertEquals(1, pages.size()); + assertEquals("Tone", pages.get(0).get("page_name")); + assertThrows(BitwigApiException.class, () -> facade.getDeviceParameters(0, 0, 3)); + } + + /** Regression: value().set() is silently ignored by Bitwig; the write must use setImmediately(). */ + @Test + void setsByPageAndIndexAndActuallyApplies() throws Exception { + Map r = facade.setDeviceParameters(0, 0, + List.of(new BitwigEditFacade.ParamAssignment(0, 1, null, 0.9))); + Map result = list(r.get("results")).get(0); + assertEquals("success", result.get("status")); + assertEquals(true, result.get("applied")); + assertEquals("Pre-delay", result.get("name")); + assertEquals(0.9, values[0][1], 1e-9); + } + + @Test + void setsByNameSearchingAcrossPages() throws Exception { + Map r = facade.setDeviceParameters(0, 0, + List.of(new BitwigEditFacade.ParamAssignment(null, null, "damping", 0.25))); + Map result = list(r.get("results")).get(0); + assertEquals(1, result.get("page")); + assertEquals("Damping", result.get("name")); + assertEquals(0.25, values[1][2], 1e-9); + } + + @Test + void eachAssignmentFailsOnItsOwn() throws Exception { + Map r = facade.setDeviceParameters(0, 0, List.of( + new BitwigEditFacade.ParamAssignment(null, null, "Nope", 0.5), + new BitwigEditFacade.ParamAssignment(0, 7, null, 1.5), + new BitwigEditFacade.ParamAssignment(2, 5, null, 0.5), + new BitwigEditFacade.ParamAssignment(0, 7, null, 0.4))); + List> results = list(r.get("results")); + assertEquals("error", results.get(0).get("status")); + assertEquals("error", results.get(1).get("status")); + assertEquals("error", results.get(2).get("status")); // page 2 has only one control + assertEquals("success", results.get(3).get("status")); + assertEquals(0.4, values[0][7], 1e-9); + } + + @Test + void focusFailsWhenCursorLandsOnAnotherDevice() { + when(device.name().get()).thenReturn("EQ+"); // cursor still reports "Reverb" + BitwigApiException e = assertThrows(BitwigApiException.class, () -> facade.getDeviceParameters(0, 0, null)); + assertEquals(ErrorCode.BITWIG_TIMEOUT, e.getErrorCode()); + } + + @Test + void volumeDbLandsOnTheClosestDisplayedValue() throws Exception { + for (double target : new double[]{-3.0, 0.0, -12.5, 4.0}) { + facade.setTrackProperties(0, null, null, target, null, null, null, null, null, null, null); + double shown = BitwigEditFacade.parseDb(faderDisplay(fader.get())); + assertEquals(target, shown, 0.051, "target " + target); + } + } + + @Test + void createClipThenWriteNotesOnTheGrid() throws Exception { + assertEquals(true, facade.createClip(0, 0, 4).get("created")); + assertThrows(BitwigApiException.class, () -> facade.createClip(0, 0, 4)); // occupied now + + Map r = facade.writeNotes(0, 0, List.of( + new BitwigEditFacade.Note(0.0, 60, 100, 1.0), + new BitwigEditFacade.Note(1.5, 64, 200, 0.5), // velocity clamped + new BitwigEditFacade.Note(0.25, 128, 100, 1.0) // out-of-range pitch skipped + ), true); + assertEquals(2, r.get("notes_written")); + verify(clip).clearSteps(); + verify(clip).setStep(0, 60, 100, 1.0); + verify(clip).setStep(24, 64, 127, 0.5); // 1.5 beats / (1/16 beat) + verify(slot0).select(); + } + + @Test + void writeNotesNeedsAClip() { + BitwigApiException e = assertThrows(BitwigApiException.class, + () -> facade.writeNotes(0, 0, List.of(new BitwigEditFacade.Note(0, 60, 100, 1)), false)); + assertEquals(ErrorCode.CLIP_NOT_FOUND, e.getErrorCode()); + } + + @Test + void insertFileIntoSlot(@TempDir Path dir) throws Exception { + Path midi = Files.write(dir.resolve("chord.mid"), new byte[]{0x4d, 0x54, 0x68, 0x64}); + InsertionPoint ip = mock(InsertionPoint.class); + when(slot0.replaceInsertionPoint()).thenReturn(ip); + doAnswer(i -> { + slotHasContent.set(true); + return null; + }).when(ip).insertFile(anyString()); + Map r = facade.insertFile(0, 2, midi.toString()); + verify(ip).insertFile(midi.toString()); + assertEquals(true, r.get("inserted")); + assertThrows(BitwigApiException.class, () -> facade.insertFile(0, 99, midi.toString())); + assertNull(r.get("new_tracks_created")); + } + + @Test + void setTransportOnlyTouchesGivenFields() { + facade.setTransport(128.0, null, true, null, null, null); + verify(transport.tempo()).setRaw(128.0); + verify(transport.isArrangerLoopEnabled()).set(true); + verify(transport.isMetronomeEnabled(), never()).set(anyBoolean()); + verify(transport.getPosition(), never()).set(anyDouble()); + } + + @Test + void deleteDeviceAndDuplicateTrack() throws Exception { + assertEquals("Reverb", facade.deleteDevice(0, 0).get("deleted_device")); + verify(device).deleteObject(); + facade.duplicateTrack(0); + verify(track).duplicate(); + } +} diff --git a/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeTest.java b/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeTest.java new file mode 100644 index 00000000..2bea0d9e --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/BitwigEditFacadeTest.java @@ -0,0 +1,259 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +class BitwigEditFacadeTest { + + private static final UUID EQ_PLUS = UUID.fromString("e4815188-ba6f-4d14-bcfc-2dcb8f778ccb"); + + private ControllerHost host; + private Application application; + private TrackBank trackBank; + private Track track0; + private DeviceBank deviceBank0; + private Device device0; + private BitwigEditFacade facade; + + @BeforeEach + void setUp() { + host = mock(ControllerHost.class, RETURNS_DEEP_STUBS); + application = mock(Application.class); + when(application.panelLayout()).thenReturn(mock(StringValue.class)); + Transport transport = mock(Transport.class, RETURNS_DEEP_STUBS); + trackBank = mock(TrackBank.class); + track0 = mock(Track.class, RETURNS_DEEP_STUBS); + deviceBank0 = mock(DeviceBank.class); + device0 = mock(Device.class, RETURNS_DEEP_STUBS); + + when(trackBank.getSizeOfBank()).thenReturn(1); + when(trackBank.getItemAt(0)).thenReturn(track0); + when(track0.exists().get()).thenReturn(true); + when(track0.name().get()).thenReturn("Up Vocals"); + when(track0.clipLauncherSlotBank().getSizeOfBank()).thenReturn(0); + when(deviceBank0.getSizeOfBank()).thenReturn(1); + when(deviceBank0.getItemAt(0)).thenReturn(device0); + when(device0.exists().get()).thenReturn(true); + when(device0.name().get()).thenReturn("Vocoder"); + + facade = new BitwigEditFacade(host, mock(Logger.class), application, transport, trackBank, + List.of(deviceBank0), 0); + } + + @Test + void resolvesTrackByNameAndIndex() throws Exception { + assertEquals(0, facade.resolveTrackIndex(null, "Up Vocals", "op")); + assertEquals(0, facade.resolveTrackIndex(0, null, "op")); + } + + @Test + void unknownTrackIsTrackNotFound() { + BitwigApiException e = assertThrows(BitwigApiException.class, + () -> facade.resolveTrackIndex(null, "Nope", "op")); + assertEquals(ErrorCode.TRACK_NOT_FOUND, e.getErrorCode()); + assertThrows(BitwigApiException.class, () -> facade.resolveTrackIndex(5, null, "op")); + assertThrows(BitwigApiException.class, () -> facade.resolveTrackIndex(null, null, "op")); + } + + @Test + void bundledDeviceListResolvesCaseInsensitively() { + assertEquals(EQ_PLUS, facade.findBitwigDevice("eq+")); + assertNotNull(facade.findBitwigDevice("Vocoder")); + assertNotNull(facade.findBitwigDevice("Reverb")); + assertNull(facade.findBitwigDevice("Not A Device")); + assertTrue(facade.listBitwigDeviceNames().size() > 100); + } + + @Test + void readsDeviceMetadataFromBitwigPresetHeaders(@org.junit.jupiter.api.io.TempDir java.nio.file.Path dir) throws Exception { + java.io.ByteArrayOutputStream b = new java.io.ByteArrayOutputStream(); + b.write("BtWg0003meta".getBytes()); + for (String[] kv : new String[][]{{"device_id", "01af068e-1e49-4777-a6e6-7f1dc679227a"}, + {"device_name", "EQ-2"}, {"device_type", "audio_to_audio"}, {"device_category", "EQ"}}) { + b.write(new byte[]{0, 0, 0, (byte) kv[0].length()}); + b.write(kv[0].getBytes()); + b.write(new byte[]{8, 0, 0, 0, (byte) kv[1].length()}); + b.write(kv[1].getBytes()); + b.write(new byte[]{0, 0, 0, 1}); + } + java.nio.file.Path d = java.nio.file.Files.createDirectories(dir.resolve("01af068e-1e49-4777-a6e6-7f1dc679227a")); + java.nio.file.Files.write(d.resolve("Default.bwpreset"), b.toByteArray()); + Map lib = BitwigEditFacade.readLibrary(dir); + BitwigEditFacade.BitwigDevice eq = lib.get("EQ-2"); + assertEquals(UUID.fromString("01af068e-1e49-4777-a6e6-7f1dc679227a"), eq.id()); + assertEquals("audio effect", eq.type()); + assertEquals("EQ", eq.category()); + assertTrue(BitwigEditFacade.readLibrary(dir.resolve("missing")).isEmpty()); + } + + @Test + void insertBitwigDeviceAtEndOfChain() throws Exception { + InsertionPoint end = mock(InsertionPoint.class); + when(track0.endOfDeviceChainInsertionPoint()).thenReturn(end); + facade.insertDevice(0, "EQ+", null, null, null, null); + verify(end).insertBitwigDevice(EQ_PLUS); + } + + @Test + void insertClapAfterDevice() throws Exception { + InsertionPoint after = mock(InsertionPoint.class); + when(device0.afterDeviceInsertionPoint()).thenReturn(after); + facade.insertDevice(0, null, "org.surge-synth-team.surge-xt", null, null, 0); + verify(after).insertCLAPDevice("org.surge-synth-team.surge-xt"); + } + + @Test + void insertUnknownDeviceFails() { + when(track0.endOfDeviceChainInsertionPoint()).thenReturn(mock(InsertionPoint.class)); + BitwigApiException e = assertThrows(BitwigApiException.class, + () -> facade.insertDevice(0, "Nope", null, null, null, null)); + assertEquals(ErrorCode.DEVICE_NOT_FOUND, e.getErrorCode()); + } + + @Test + void insertWithoutAnyIdFails() { + when(track0.endOfDeviceChainInsertionPoint()).thenReturn(mock(InsertionPoint.class)); + assertThrows(BitwigApiException.class, () -> facade.insertDevice(0, null, null, null, null, null)); + } + + @Test + void copyDeviceUsesInsertionPoint() throws Exception { + InsertionPoint start = mock(InsertionPoint.class); + when(track0.startOfDeviceChainInsertionPoint()).thenReturn(start); + facade.copyOrMoveDevice(0, 0, 0, "start", null, false); + verify(start).copyDevices(device0); + facade.copyOrMoveDevice(0, 0, 0, "start", null, true); + verify(start).moveDevices(device0); + } + + @Test + void missingDeviceIsDeviceNotFound() { + BitwigApiException e = assertThrows(BitwigApiException.class, () -> facade.setDeviceEnabled(0, 3, true)); + assertEquals(ErrorCode.DEVICE_NOT_FOUND, e.getErrorCode()); + } + + @Test + void setDeviceEnabledSetsValue() throws Exception { + facade.setDeviceEnabled(0, 0, true); + verify(device0.isEnabled()).set(true); + } + + @Test + void createTrackCallsApplication() throws Exception { + assertThrows(BitwigApiException.class, () -> facade.createTrack("audio", null, null)); // never appears: timeout + verify(application).createAudioTrack(1); + assertThrows(BitwigApiException.class, () -> facade.createTrack("midi", null, null)); + assertThrows(BitwigApiException.class, () -> facade.createTrack("audio", 9, null)); + } + + @Test + void setTrackPropertiesMapsPanAndColor() throws Exception { + facade.setTrackProperties(0, "Stack", null, null, -1.0, true, null, null, null, "auto", "#FF8000"); + verify(track0.name()).set("Stack"); + verify(track0.pan().value()).setImmediately(0.0); + verify(track0.mute()).set(true); + verify(track0.monitorMode()).set("AUTO"); + verify(track0.color()).set(1.0f, 128 / 255f, 0.0f); + } + + /** Regression: the master track sits at the end of the flat track list and must never be renamed or deleted. */ + @Test + void masterTrackIsNeverTreatedAsANewTrack() throws Exception { + Track master = mock(Track.class, RETURNS_DEEP_STUBS); + when(master.exists().get()).thenReturn(true); + when(master.trackType().get()).thenReturn("Master"); + when(master.name().get()).thenReturn("Master"); + when(master.clipLauncherSlotBank().getSizeOfBank()).thenReturn(0); + when(trackBank.getSizeOfBank()).thenReturn(2); + when(trackBank.getItemAt(1)).thenReturn(master); + DeviceBank emptyBank = mock(DeviceBank.class); + facade = new BitwigEditFacade(host, mock(Logger.class), application, mock(Transport.class, RETURNS_DEEP_STUBS), + trackBank, List.of(deviceBank0, emptyBank), 0); + + assertEquals(1, facade.countTracks()); + // Bitwig hasn't shown the new track yet, so nothing may be renamed + assertThrows(BitwigApiException.class, () -> facade.createTrack("audio", null, "New")); + verify(application).createAudioTrack(1); + verify(master.name(), never()).set(anyString()); + + // Even if the count goes up, a track of the wrong type at the target index is not renamed + when(track0.trackType().get()).thenReturn("Audio"); + Track effect = mock(Track.class, RETURNS_DEEP_STUBS); + when(effect.exists().get()).thenReturn(true); + when(effect.trackType().get()).thenReturn("Effect"); + when(trackBank.getSizeOfBank()).thenReturn(3); + when(trackBank.getItemAt(1)).thenReturn(effect); + when(trackBank.getItemAt(2)).thenReturn(master); + assertEquals(1, facade.countTracks()); + assertEquals(1, facade.countEffectTracks()); + + BitwigApiException e = assertThrows(BitwigApiException.class, () -> facade.deleteTrack(2)); + assertEquals(ErrorCode.INVALID_PARAMETER, e.getErrorCode()); + verify(master, never()).deleteObject(); + assertThrows(BitwigApiException.class, () -> facade.duplicateTrack(2)); + } + + @Test + void mixerStateSurvivesMissingSendBank() { + when(track0.sendBank()).thenThrow(new IllegalStateException("No send bank exists")); + Map state = facade.trackMixerState(0); + assertEquals(List.of(), state.get("sends")); + assertThrows(BitwigApiException.class, () -> facade.setSend(0, 0, 0.5, null)); + } + + @Test + void parsesDbDisplay() { + assertEquals(-3.0, BitwigEditFacade.parseDb("-3.00 dB")); + assertEquals(-3.0, BitwigEditFacade.parseDb("−3.00 dB")); + assertEquals(Double.NEGATIVE_INFINITY, BitwigEditFacade.parseDb("-inf dB")); + assertNull(BitwigEditFacade.parseDb("loud")); + assertNull(BitwigEditFacade.parseDb(null)); + } + + @Test + void parsesColor() throws Exception { + assertArrayEquals(new int[]{255, 0, 16}, BitwigEditFacade.parseColor("#ff0010", "op")); + assertThrows(BitwigApiException.class, () -> BitwigEditFacade.parseColor("red", "op")); + } + + @Test + void invokeActionRejectsUnknownId() throws Exception { + assertThrows(BitwigApiException.class, () -> facade.invokeAction("nope")); + Action action = mock(Action.class); + when(action.getName()).thenReturn("Undo"); + when(application.getAction("undo")).thenReturn(action); + Map r = facade.invokeAction("undo"); + verify(action).invoke(); + assertEquals("undo", r.get("invoked")); + } + + @Test + void listActionsFilters() { + Action a = mock(Action.class, RETURNS_DEEP_STUBS); + when(a.getId()).thenReturn("toggle_metronome"); + when(a.getName()).thenReturn("Toggle Metronome"); + Action b = mock(Action.class, RETURNS_DEEP_STUBS); + when(b.getId()).thenReturn("undo"); + when(b.getName()).thenReturn("Undo"); + when(application.getActions()).thenReturn(new Action[]{a, b}); + assertEquals(1, facade.listActions("metro").size()); + assertEquals(2, facade.listActions(null).size()); + } + + @Test + void insertFileRejectsMissingFile() { + assertThrows(BitwigApiException.class, () -> facade.insertFile(0, 0, "/nonexistent/file.mid")); + } +} diff --git a/src/test/java/io/github/fabb/wigai/bitwig/BitwigSessionFacadeTest.java b/src/test/java/io/github/fabb/wigai/bitwig/BitwigSessionFacadeTest.java new file mode 100644 index 00000000..0ee790ee --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/BitwigSessionFacadeTest.java @@ -0,0 +1,102 @@ +package io.github.fabb.wigai.bitwig; + +import com.bitwig.extension.callback.IntegerValueChangedCallback; +import com.bitwig.extension.controller.api.*; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class BitwigSessionFacadeTest { + + private ControllerHost host; + private Track track; + private BitwigSessionFacade facade; + private final ArgumentCaptor meters = ArgumentCaptor.forClass(IntegerValueChangedCallback.class); + + @BeforeEach + void setUp() { + host = mock(ControllerHost.class, RETURNS_DEEP_STUBS); + TrackBank trackBank = mock(TrackBank.class); + track = mock(Track.class, RETURNS_DEEP_STUBS); + when(trackBank.getSizeOfBank()).thenReturn(1); + when(trackBank.getItemAt(0)).thenReturn(track); + when(track.exists().get()).thenReturn(true); + when(track.name().get()).thenReturn("Vox"); + when(track.trackType().get()).thenReturn("Audio"); + when(host.createArranger().createCueMarkerBank(anyInt()).getSizeOfBank()).thenReturn(0); + when(host.createSceneBank(anyInt()).getSizeOfBank()).thenReturn(0); + facade = new BitwigSessionFacade(host, mock(Logger.class), mock(Transport.class, RETURNS_DEEP_STUBS), trackBank, 0); + verify(track, times(2)).addVuMeterObserver(eq(1000), eq(-1), anyBoolean(), meters.capture()); + } + + @Test + @SuppressWarnings("unchecked") + void levelsAreNormalizedFromMeterCallbacks() { + meters.getAllValues().get(0).valueChanged(500); // peak + meters.getAllValues().get(1).valueChanged(250); // rms + Map levels = facade.getLevels(); + Map vox = ((List>) levels.get("tracks")).get(0); + assertEquals("Vox", vox.get("name")); + assertEquals(0.5, vox.get("peak")); + assertEquals(0.25, vox.get("rms")); + } + + @Test + void playNotesSchedulesStartAndStopForEachNote() { + Map r = facade.playNotes(0, List.of( + new BitwigSessionFacade.AuditionNote(60, 100, 0, 500), + new BitwigSessionFacade.AuditionNote(64, 200, 100, 500))); + verify(host).scheduleTask(any(Runnable.class), eq(0L)); + verify(host).scheduleTask(any(Runnable.class), eq(500L)); + verify(host).scheduleTask(any(Runnable.class), eq(100L)); + verify(host).scheduleTask(any(Runnable.class), eq(600L)); + assertEquals(600L, r.get("total_ms")); + } + + @Test + void stopTrackOrReturnToArrangement() { + facade.stopTrack(0, false); + verify(track).stop(); + facade.stopTrack(0, true); + verify(track).returnToArrangement(); + } + + @Test + void unknownSceneAndCueMarkerAreErrors() { + BitwigApiException e = assertThrows(BitwigApiException.class, () -> facade.renameScene(3, "Chorus")); + assertEquals(ErrorCode.SCENE_NOT_FOUND, e.getErrorCode()); + assertThrows(BitwigApiException.class, () -> facade.goToCueMarker(0, null, false)); + } + + @Test + void readNotesNeedsAClip() { + when(track.clipLauncherSlotBank().getSizeOfBank()).thenReturn(4); + when(track.clipLauncherSlotBank().getItemAt(0).hasContent().get()).thenReturn(false); + BitwigApiException e = assertThrows(BitwigApiException.class, () -> facade.readNotes(0, 0)); + assertEquals(ErrorCode.CLIP_NOT_FOUND, e.getErrorCode()); + } + + @Test + void midiMessagesAreEncoded() throws Exception { + assertArrayEquals(new int[]{0xB2, 1, 100}, BitwigSessionFacade.midiMessage("cc", 2, 1, 100, null, "op")); + assertArrayEquals(new int[]{0xE0, 0, 64}, BitwigSessionFacade.midiMessage("pitch_bend", 0, null, 0, null, "op")); + assertArrayEquals(new int[]{0xE0, 0x7F, 0x7F}, BitwigSessionFacade.midiMessage("pitch_bend", 0, null, 8191, null, "op")); + assertArrayEquals(new int[]{0xE0, 0, 0}, BitwigSessionFacade.midiMessage("pitch_bend", 0, null, -8192, null, "op")); + assertArrayEquals(new int[]{0xC0, 5, 0}, BitwigSessionFacade.midiMessage("program", 0, 5, null, null, "op")); + assertArrayEquals(new int[]{0x90, 60, 0}, BitwigSessionFacade.midiMessage("raw", 0, 60, null, 0x90, "op")); + assertThrows(BitwigApiException.class, () -> BitwigSessionFacade.midiMessage("cc", 0, 1, 128, null, "op")); + assertThrows(BitwigApiException.class, () -> BitwigSessionFacade.midiMessage("pitch_bend", 0, null, 9000, null, "op")); + assertThrows(BitwigApiException.class, () -> BitwigSessionFacade.midiMessage("raw", 0, 1, 1, 0xF0, "op")); + assertThrows(BitwigApiException.class, () -> BitwigSessionFacade.midiMessage("sysex", 0, 1, 1, null, "op")); + } +} diff --git a/src/test/java/io/github/fabb/wigai/bitwig/EventBufferTest.java b/src/test/java/io/github/fabb/wigai/bitwig/EventBufferTest.java new file mode 100644 index 00000000..a74c213e --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/EventBufferTest.java @@ -0,0 +1,83 @@ +package io.github.fabb.wigai.bitwig; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.*; + +class EventBufferTest { + + private final AtomicLong clock = new AtomicLong(1000); + + private EventBuffer buffer(int capacity) { + return new EventBuffer(capacity, 250, clock::get); + } + + @Test + void eventsAreNumberedAndFilteredByType() throws Exception { + EventBuffer b = buffer(10); + b.add("track", "track:0:mute", Map.of("value", true)); + b.add("transport", "transport:playing", Map.of("playing", true)); + assertEquals(2, b.latestSeq()); + assertEquals(2, b.since(0, null, 0).size()); + List transport = b.since(0, List.of("transport"), 0); + assertEquals(1, transport.size()); + assertEquals(2, transport.get(0).seq()); + assertTrue(b.since(2, null, 0).isEmpty()); + assertEquals("transport", transport.get(0).toMap().get("type")); + assertEquals(true, transport.get(0).toMap().get("playing")); + } + + @Test + void rapidChangesToOneThingKeepOnlyTheLatest() throws Exception { + EventBuffer b = buffer(10); + b.add("track", "track:0:volume", Map.of("value", "-1.0 dB")); + clock.addAndGet(100); + b.add("track", "track:0:volume", Map.of("value", "-2.0 dB")); + List all = b.since(0, null, 0); + assertEquals(1, all.size()); + assertEquals("-2.0 dB", all.get(0).data().get("value")); + assertEquals(2, all.get(0).seq()); + + clock.addAndGet(1000); + b.add("track", "track:0:volume", Map.of("value", "-3.0 dB")); + assertEquals(2, b.since(0, null, 0).size(), "changes further apart than the window are kept"); + } + + @Test + void overflowIsReportedAsMissedEvents() throws Exception { + EventBuffer b = buffer(3); + for (int i = 0; i < 5; i++) { + b.add("track", "k" + i, Map.of()); + } + assertEquals(3, b.since(0, null, 0).size()); + assertTrue(b.missedSince(0)); + assertTrue(b.missedSince(1)); + assertFalse(b.missedSince(2)); + } + + @Test + void waitingReturnsAsSoonAsAnEventArrives() throws Exception { + EventBuffer b = new EventBuffer(10, 250); + Thread t = new Thread(() -> { + try { + Thread.sleep(150); + } catch (InterruptedException ignored) { + } + b.add("clip", "clip:0:0", Map.of("state", "playing")); + }); + long start = System.currentTimeMillis(); + t.start(); + List got = b.since(0, null, 5000); + long took = System.currentTimeMillis() - start; + assertEquals(1, got.size()); + assertTrue(took < 2000, "returned after " + took + " ms"); + + start = System.currentTimeMillis(); + assertTrue(b.since(1, null, 200).isEmpty()); + assertTrue(System.currentTimeMillis() - start >= 150, "waits when nothing arrives"); + } +} diff --git a/src/test/java/io/github/fabb/wigai/bitwig/PluginCacheTest.java b/src/test/java/io/github/fabb/wigai/bitwig/PluginCacheTest.java new file mode 100644 index 00000000..eba98c28 --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/bitwig/PluginCacheTest.java @@ -0,0 +1,133 @@ +package io.github.fabb.wigai.bitwig; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class PluginCacheTest { + + /** Builds cache bytes in Bitwig's tagged format: 4-byte tag, type byte, then the value. */ + private static final class Cache { + final ByteArrayOutputStream b = new ByteArrayOutputStream(); + + Cache raw(byte... bytes) { + b.writeBytes(bytes); + return this; + } + + Cache record() { + return raw(new byte[]{0, 0, 0, 1}).raw("BtWg0001000200000000003900".getBytes(StandardCharsets.US_ASCII)); + } + + Cache str(int tag, String v) { + byte[] s = v.getBytes(StandardCharsets.UTF_8); + return raw(i(tag)).raw((byte) 0x08).raw(i(s.length)).raw(s); + } + + Cache integer(int tag, int v) { + return raw(i(tag)).raw((byte) 0x03).raw(i(v)); + } + + Cache strings(int tag, String... vs) { + raw(i(tag)).raw((byte) 0x19).raw(i(vs.length)); + for (String v : vs) { + byte[] s = v.getBytes(StandardCharsets.UTF_8); + raw(i(s.length)).raw(s); + } + return this; + } + + static byte[] i(int v) { + return new byte[]{(byte) (v >> 24), (byte) (v >> 16), (byte) (v >> 8), (byte) v}; + } + + byte[] bytes() { + return b.toByteArray(); + } + } + + @Test + void parsesClapFilesWithSeveralPlugins() { + byte[] data = new Cache() + .record().str(0x3eb, "lsp").str(0x3ec, "/usr/lib/clap/lsp-plugins.clap").raw(new byte[]{0, 0, 3, (byte) 0xef, 4}) + .str(0x3e3, "Dynamics").str(0x3e4, "Compressor Mono").str(0x3e6, "LSP").str(0x2ec4, "in.lsp-plug.comp_mono") + .str(0x2ec5, "1.0.37").strings(0x34f8, "audio-effect", "compressor", "mono") + .str(0x3e3, "EQ").str(0x3e4, "Graphic EQ").str(0x3e6, "LSP").str(0x2ec4, "in.lsp-plug.geq") + .str(0x2ec5, "").strings(0x34f8, "audio-effect", "equalizer") + .record().str(0x3ec, "/usr/lib/clap/Surge XT.clap") + .str(0x3e3, "Synth").str(0x3e4, "Surge XT").str(0x3e6, "Surge Synth Team") + .str(0x2ec4, "org.surge-synth-team.surge-xt").strings(0x34f8, "instrument", "synthesizer") + .bytes(); + + List ps = PluginCache.parse("clap", data); + assertEquals(3, ps.size()); + PluginCache.Plugin comp = ps.get(0); + assertEquals("in.lsp-plug.comp_mono", comp.id()); + assertEquals("Compressor Mono", comp.name()); + assertEquals("Dynamics", comp.category()); + assertEquals("1.0.37", comp.version()); + assertEquals(List.of("audio-effect", "compressor", "mono"), comp.features()); + assertEquals("/usr/lib/clap/lsp-plugins.clap", comp.file()); + assertNull(ps.get(1).version(), "empty version is left out"); + assertEquals("/usr/lib/clap/lsp-plugins.clap", ps.get(1).file()); + assertEquals("/usr/lib/clap/Surge XT.clap", ps.get(2).file()); + assertEquals(List.of("instrument", "synthesizer"), ps.get(2).features()); + } + + @Test + void parsesVst3StringIdsAndVst2IntegerIds() { + byte[] vst3 = new Cache().record().str(0x3ec, "/usr/lib/vst3/ZaMaximX2.vst3") + .str(0x3e3, "Audio FX").str(0x3e4, "ZaMaximX2").str(0x3e6, "Damien Zammit") + .str(0x3e2, "2046504473616C6332584D5A00000000").str(0xc58, "4.5.0").bytes(); + PluginCache.Plugin p3 = PluginCache.parse("vst3", vst3).get(0); + assertEquals("2046504473616C6332584D5A00000000", p3.id()); + assertEquals("4.5.0", p3.version()); + + byte[] vst2 = new Cache().record().str(0x3ec, "/usr/lib/vst/ZaMaximX2-vst.so") + .str(0x3e3, "Audio FX").str(0x3e4, "ZaMaximX2").str(0x3e6, "Damien Zammit") + .integer(0x3e2, 1515018290).integer(0xc58, 263424).bytes(); + PluginCache.Plugin p2 = PluginCache.parse("vst2", vst2).get(0); + assertEquals("1515018290", p2.id()); + assertNull(p2.version()); + } + + @Test + void entriesWithoutAnIdAreSkippedAndGarbageIsTolerated() { + byte[] data = new Cache().raw(new byte[]{9, 9, 9}).record().str(0x3e3, "Synth").str(0x3e4, "No Id") + .raw(new byte[]{0, 0, 3, (byte) 0xe4, 8, 0x7f, 0x7f, 0x7f, 0x7f}).bytes(); + assertTrue(PluginCache.parse("clap", data).isEmpty()); + assertTrue(PluginCache.parse("clap", new byte[0]).isEmpty()); + } + + @Test + void readsTheNewestCacheFileOfEachKind(@TempDir Path dir) throws Exception { + byte[] old = new Cache().record().str(0x3e3, "Synth").str(0x3e4, "Old").str(0x2ec4, "old.id").bytes(); + byte[] cur = new Cache().record().str(0x3e3, "Synth").str(0x3e4, "New").str(0x2ec4, "new.id").bytes(); + Files.write(dir.resolve("clap-metadata-9"), old); + Files.write(dir.resolve("clap-metadata-13"), cur); + Files.write(dir.resolve("vst-metadata-27"), new Cache().record().str(0x3e3, "Audio FX").str(0x3e4, "V2") + .integer(0x3e2, 42).bytes()); + List ps = PluginCache.read(dir); + assertEquals(List.of("new.id", "42"), ps.stream().map(PluginCache.Plugin::id).toList()); + assertEquals(List.of("clap", "vst2"), ps.stream().map(PluginCache.Plugin::kind).toList()); + assertTrue(PluginCache.read(dir.resolve("missing")).isEmpty()); + } + + @Test + void readsThisMachinesCacheWhenPresent() { + Path real = PluginCache.defaultDir(); + if (PluginCache.newest(real, "clap-metadata-") == null) { + return; + } + List ps = PluginCache.read(real); + assertFalse(ps.isEmpty()); + assertTrue(ps.stream().allMatch(p -> !p.id().isBlank() && !p.name().isBlank())); + } +} diff --git a/src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java b/src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java new file mode 100644 index 00000000..d0c08081 --- /dev/null +++ b/src/test/java/io/github/fabb/wigai/mcp/tool/EditToolsTest.java @@ -0,0 +1,222 @@ +package io.github.fabb.wigai.mcp.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.fabb.wigai.bitwig.BitwigContainerFacade; +import io.github.fabb.wigai.bitwig.BitwigEditFacade; +import io.github.fabb.wigai.bitwig.BitwigProjectFacade; +import io.github.fabb.wigai.bitwig.BitwigSessionFacade; +import io.github.fabb.wigai.common.Logger; +import io.github.fabb.wigai.common.error.BitwigApiException; +import io.github.fabb.wigai.common.error.ErrorCode; +import io.github.fabb.wigai.common.logging.StructuredLogger; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class EditToolsTest { + + private BitwigEditFacade facade; + private List specs; + + @BeforeEach + void setUp() { + facade = mock(BitwigEditFacade.class); + specs = EditTools.specifications(facade, new StructuredLogger(mock(Logger.class), "test")); + } + + private McpSchema.CallToolResult call(String name, Map args) { + var spec = specs.stream().filter(s -> s.tool().name().equals(name)).findFirst().orElseThrow(); + return spec.callHandler().apply(null, new McpSchema.CallToolRequest(name, args)); + } + + @Test + void toolNamesAreUniqueAndSchemasAreValidJson() throws Exception { + ObjectMapper om = new ObjectMapper(); + Set names = new HashSet<>(); + for (var s : specs) { + assertTrue(names.add(s.tool().name()), "duplicate tool " + s.tool().name()); + String json = om.writeValueAsString(s.tool().inputSchema()); + assertEquals("object", om.readTree(json).get("type").asText()); + } + assertTrue(names.containsAll(List.of("create_track", "insert_device", "set_device_parameters", + "get_device_parameters", "set_track", "write_notes", "invoke_action", "undo"))); + } + + @Test + void createTrackPassesArguments() throws Exception { + when(facade.createTrack("instrument", 2, "Chords")).thenReturn(Map.of("track_index", 2)); + var r = call("create_track", Map.of("type", "instrument", "position", 2, "name", "Chords")); + assertEquals(2, McpResponseTestUtils.validateSuccessResponse(r).get("track_index").asInt()); + } + + @Test + void missingRequiredArgumentIsAnError() { + var r = call("create_track", Map.of()); + assertTrue(r.isError()); + verifyNoInteractions(facade); + } + + @Test + void wrongTypeIsAnError() { + var r = call("create_track", Map.of("type", 5)); + assertTrue(r.isError()); + } + + @Test + void trackNameResolvesThroughFacade() throws Exception { + when(facade.resolveTrackIndex(null, "Up Vocals", "set_device_enabled")).thenReturn(13); + when(facade.setDeviceEnabled(13, 4, null, null, true)).thenReturn(Map.of("enabled", true)); + var r = call("set_device_enabled", Map.of("track_name", "Up Vocals", "device_index", 4, "enabled", true)); + McpResponseTestUtils.validateSuccessResponse(r); + verify(facade).setDeviceEnabled(13, 4, null, null, true); + } + + @Test + void facadeErrorsBecomeErrorResponses() throws Exception { + when(facade.resolveTrackIndex(any(), any(), anyString())) + .thenThrow(new BitwigApiException(ErrorCode.TRACK_NOT_FOUND, "op", "No track named 'x'")); + var r = call("delete_track", Map.of("track_name", "x")); + assertTrue(r.isError()); + String text = ((McpSchema.TextContent) r.content().get(0)).text(); + assertTrue(text.contains("TRACK_NOT_FOUND")); + } + + @Test + void setDeviceParametersBuildsAssignments() throws Exception { + when(facade.resolveTrackIndex(1, null, "set_device_parameters")).thenReturn(1); + when(facade.setDeviceParameters(eq(1), eq(0), isNull(), isNull(), anyList(), eq(true))).thenReturn(Map.of()); + var r = call("set_device_parameters", Map.of("track_index", 1, "device_index", 0, + "parameters", List.of(Map.of("name", "Mix", "value", 0.35), Map.of("page", 1, "index", 2, "value", 1)))); + McpResponseTestUtils.validateSuccessResponse(r); + verify(facade).setDeviceParameters(eq(1), eq(0), isNull(), isNull(), eq(List.of( + new BitwigEditFacade.ParamAssignment(null, null, "Mix", 0.35), + new BitwigEditFacade.ParamAssignment(1, 2, null, 1.0))), eq(true)); + } + + @Test + void chainArgumentsReachTheFacade() throws Exception { + when(facade.resolveTrackIndex(null, "Vocoder Stack", "insert_device")).thenReturn(14); + when(facade.insertDevice(14, 0, "Modulator", "Audio Receiver", null, null, null, null, null, null)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(call("insert_device", Map.of("track_name", "Vocoder Stack", + "device_index", 0, "chain", "Modulator", "bitwig_device", "Audio Receiver"))); + + when(facade.resolveTrackIndex(14, null, "move_device")).thenReturn(14); + when(facade.copyOrMoveDevice(14, 0, "Modulator", 0, 14, 0, "Carrier", null, null, true)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(call("move_device", Map.of("from_track_index", 14, + "from_device_index", 0, "from_chain", "Modulator", "from_chain_device_index", 0, + "to_track_index", 14, "to_device_index", 0, "to_chain", "Carrier"))); + + when(facade.resolveTrackIndex(14, null, "get_device_parameters")).thenReturn(14); + when(facade.getDeviceParameters(14, 0, "Carrier", 1, null)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(call("get_device_parameters", Map.of("track_index", 14, + "device_index", 0, "chain", "Carrier", "chain_device_index", 1))); + } + + @Test + void setDeviceParametersRequiresValue() { + var r = call("set_device_parameters", Map.of("track_index", 1, "device_index", 0, + "parameters", List.of(Map.of("name", "Mix")))); + assertTrue(r.isError()); + } + + @Test + void writeNotesDefaultsVelocity() throws Exception { + when(facade.resolveTrackIndex(0, null, "write_notes")).thenReturn(0); + when(facade.writeNotes(eq(0), eq(0), anyList(), eq(true))).thenReturn(Map.of()); + var r = call("write_notes", Map.of("track_index", 0, "slot_index", 0, "clear_first", true, + "notes", List.of(Map.of("start", 0, "pitch", 60, "duration", 1.5)))); + McpResponseTestUtils.validateSuccessResponse(r); + verify(facade).writeNotes(0, 0, List.of(new BitwigEditFacade.Note(0, 60, 100, 1.5)), true); + } + + @Test + void presetFileReachesInsertAndReplace() throws Exception { + when(facade.resolveTrackIndex(0, null, "insert_device")).thenReturn(0); + when(facade.insertDevice(0, null, null, null, null, null, null, "/p/Kit.bwpreset", null, null)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(call("insert_device", + Map.of("track_index", 0, "preset_file", "/p/Kit.bwpreset"))); + + when(facade.resolveTrackIndex(1, null, "replace_device")).thenReturn(1); + when(facade.replaceDevice(1, 2, "FX", 0, "Delay+", null, null, null, null)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(call("replace_device", Map.of("track_index", 1, "device_index", 2, + "chain", "FX", "chain_device_index", 0, "bitwig_device", "Delay+"))); + verify(facade).replaceDevice(1, 2, "FX", 0, "Delay+", null, null, null, null); + } + + private McpSchema.CallToolResult callContainer(BitwigContainerFacade c, String name, Map args) { + var spec = EditTools.containerSpecifications(facade, c, new StructuredLogger(mock(Logger.class), "test")).stream() + .filter(s -> s.tool().name().equals(name)).findFirst().orElseThrow(); + return spec.callHandler().apply(null, new McpSchema.CallToolRequest(name, args)); + } + + @Test + void containerToolsPassArguments() throws Exception { + BitwigContainerFacade c = mock(BitwigContainerFacade.class); + ObjectMapper om = new ObjectMapper(); + for (var s : EditTools.containerSpecifications(facade, c, new StructuredLogger(mock(Logger.class), "test"))) { + assertEquals("object", om.readTree(om.writeValueAsString(s.tool().inputSchema())).get("type").asText()); + } + when(facade.resolveTrackIndex(null, "Drums", "set_drum_pad")).thenReturn(3); + when(c.setDrumPad(3, 0, 36, "Kick", null, -6.0, -0.5, true, null, null)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(callContainer(c, "set_drum_pad", Map.of("track_name", "Drums", + "device_index", 0, "pad", 36, "name", "Kick", "volume_db", -6, "pan", -0.5, "mute", true))); + verify(c).setDrumPad(3, 0, 36, "Kick", null, -6.0, -0.5, true, null, null); + + when(c.filterBrowser("Bitwig Presets", Map.of("category", "Reverb"))).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(callContainer(c, "browser_filter", + Map.of("content_type", "Bitwig Presets", "filters", Map.of("category", "Reverb")))); + + assertTrue(callContainer(c, "browser_filter", Map.of("filters", Map.of("category", 5))).isError()); + assertTrue(callContainer(c, "set_drum_pad", Map.of("track_index", 0, "device_index", 0)).isError()); + } + + private McpSchema.CallToolResult callNamed(List all, String name, + Map args) { + var spec = all.stream().filter(s -> s.tool().name().equals(name)).findFirst().orElseThrow(); + return spec.callHandler().apply(null, new McpSchema.CallToolRequest(name, args)); + } + + @Test + void arrangerClipToolsSkipTrackResolution() throws Exception { + BitwigSessionFacade s = mock(BitwigSessionFacade.class); + var log = new StructuredLogger(mock(Logger.class), "test"); + var all = new java.util.ArrayList<>(EditTools.sessionSpecifications(facade, s, log)); + all.addAll(EditTools.projectSpecifications(facade, s, mock(BitwigProjectFacade.class), log)); + + when(s.readNotes(null, null, true)).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(callNamed(all, "read_notes", Map.of("arranger", true))); + verify(s).readNotes(null, null, true); + verify(facade, never()).resolveTrackIndex(any(), any(), anyString()); + + when(s.editNotes(eq(null), eq(null), eq(true), any(), any())).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(callNamed(all, "edit_notes", Map.of("arranger", true, + "occurrence", "not_first", "recurrence_length", 4, "recurrence_mask", 5, "release_velocity", 0.8))); + var changes = org.mockito.ArgumentCaptor.forClass(BitwigSessionFacade.NoteChanges.class); + verify(s).editNotes(eq(null), eq(null), eq(true), any(), changes.capture()); + assertEquals("not_first", changes.getValue().occurrence()); + assertEquals(4, changes.getValue().recurrenceLength()); + assertEquals(5, changes.getValue().recurrenceMask()); + assertEquals(0.8, changes.getValue().releaseVelocity()); + } + + @Test + void setClipBuildsSettings() throws Exception { + BitwigSessionFacade s = mock(BitwigSessionFacade.class); + var all = EditTools.sessionSpecifications(facade, s, new StructuredLogger(mock(Logger.class), "test")); + when(facade.resolveTrackIndex(2, null, "set_clip")).thenReturn(2); + when(s.setClip(eq(2), eq(0), eq(false), any())).thenReturn(Map.of()); + McpResponseTestUtils.validateSuccessResponse(callNamed(all, "set_clip", Map.of("track_index", 2, "slot_index", 0, + "shuffle", true, "accent", 0.7, "loop_enabled", false))); + verify(s).setClip(2, 0, false, new BitwigSessionFacade.ClipSettings(null, null, null, false, null, null, true, 0.7, null)); + } +}