fl-studio-agent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fl_studio_agent-0.1.0/.gitignore +18 -0
- fl_studio_agent-0.1.0/LICENSE +21 -0
- fl_studio_agent-0.1.0/PKG-INFO +69 -0
- fl_studio_agent-0.1.0/README.md +40 -0
- fl_studio_agent-0.1.0/pyproject.toml +90 -0
- fl_studio_agent-0.1.0/pytest.ini +7 -0
- fl_studio_agent-0.1.0/src/fl_mcp/__init__.py +5 -0
- fl_studio_agent-0.1.0/src/fl_mcp/__main__.py +6 -0
- fl_studio_agent-0.1.0/src/fl_mcp/actions.py +399 -0
- fl_studio_agent-0.1.0/src/fl_mcp/assets/session-template.flp +0 -0
- fl_studio_agent-0.1.0/src/fl_mcp/audio.py +350 -0
- fl_studio_agent-0.1.0/src/fl_mcp/audio_stream.py +191 -0
- fl_studio_agent-0.1.0/src/fl_mcp/clock.py +16 -0
- fl_studio_agent-0.1.0/src/fl_mcp/contract.py +264 -0
- fl_studio_agent-0.1.0/src/fl_mcp/control_broker.py +6 -0
- fl_studio_agent-0.1.0/src/fl_mcp/controller_version.py +34 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/__init__.py +3 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/__init__.py +44 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/broker_client.py +150 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/control_broker.py +219 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/spool.py +229 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/udp.py +286 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/wire.py +332 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/discovery.py +223 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/provenance.py +76 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/router.py +247 -0
- fl_studio_agent-0.1.0/src/fl_mcp/core/transport.py +95 -0
- fl_studio_agent-0.1.0/src/fl_mcp/dev_session.py +176 -0
- fl_studio_agent-0.1.0/src/fl_mcp/discover.py +225 -0
- fl_studio_agent-0.1.0/src/fl_mcp/doctor.py +24 -0
- fl_studio_agent-0.1.0/src/fl_mcp/errors.py +247 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl/__init__.py +7 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl/install.py +363 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl/paths.py +124 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl/process.py +266 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/MCP Write Notes.pyscript +396 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/__init__.py +7 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/device_FLMCP.py +613 -0
- fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/flmcp_codec.py +122 -0
- fl_studio_agent-0.1.0/src/fl_mcp/flp.py +102 -0
- fl_studio_agent-0.1.0/src/fl_mcp/gen_layout.py +177 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/__init__.py +6 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/cell.py +157 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/child.py +208 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/host.py +397 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/launch.py +190 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/private_dirs.py +28 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/site/sitecustomize.py +12 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/windows.py +542 -0
- fl_studio_agent-0.1.0/src/fl_mcp/kernel/wire.py +189 -0
- fl_studio_agent-0.1.0/src/fl_mcp/make_agent_project.py +55 -0
- fl_studio_agent-0.1.0/src/fl_mcp/observer.py +420 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/__init__.py +10 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/automation.py +34 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/levels.py +33 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/mixer.py +34 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/notes.py +52 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/params.py +34 -0
- fl_studio_agent-0.1.0/src/fl_mcp/ops/playlist.py +36 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/__init__.py +45 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/getters.py +55 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/map_align.py +149 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/plugins.py +151 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/sweep.py +67 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/target.py +42 -0
- fl_studio_agent-0.1.0/src/fl_mcp/passes/timing.py +42 -0
- fl_studio_agent-0.1.0/src/fl_mcp/pitch.py +68 -0
- fl_studio_agent-0.1.0/src/fl_mcp/platform/__init__.py +29 -0
- fl_studio_agent-0.1.0/src/fl_mcp/platform/runtime.py +245 -0
- fl_studio_agent-0.1.0/src/fl_mcp/plugin_models/README.md +11 -0
- fl_studio_agent-0.1.0/src/fl_mcp/plugin_models/__init__.py +24 -0
- fl_studio_agent-0.1.0/src/fl_mcp/profile.py +644 -0
- fl_studio_agent-0.1.0/src/fl_mcp/profiles/fl-26.1.5-5384-arm64.json +273 -0
- fl_studio_agent-0.1.0/src/fl_mcp/scene.py +408 -0
- fl_studio_agent-0.1.0/src/fl_mcp/schemas.py +497 -0
- fl_studio_agent-0.1.0/src/fl_mcp/scrape_rtti.py +470 -0
- fl_studio_agent-0.1.0/src/fl_mcp/sdk.py +293 -0
- fl_studio_agent-0.1.0/src/fl_mcp/server.py +362 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/__init__.py +28 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/check.py +283 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/controls.py +679 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/detect.py +287 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/facade.py +631 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/lifecycle.py +66 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/link.py +283 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/notes.py +360 -0
- fl_studio_agent-0.1.0/src/fl_mcp/session/state.py +319 -0
- fl_studio_agent-0.1.0/src/fl_mcp/store.py +184 -0
- fl_studio_agent-0.1.0/src/fl_mcp/template.py +24 -0
- fl_studio_agent-0.1.0/src/fl_mcp/theory.py +113 -0
- fl_studio_agent-0.1.0/tests/carrier_fakes.py +99 -0
- fl_studio_agent-0.1.0/tests/conftest.py +36 -0
- fl_studio_agent-0.1.0/tests/corpus/__init__.py +0 -0
- fl_studio_agent-0.1.0/tests/corpus/fetch.py +31 -0
- fl_studio_agent-0.1.0/tests/corpus/manifest.py +38 -0
- fl_studio_agent-0.1.0/tests/e2e/__init__.py +0 -0
- fl_studio_agent-0.1.0/tests/e2e/conftest.py +64 -0
- fl_studio_agent-0.1.0/tests/e2e/fl_host.py +114 -0
- fl_studio_agent-0.1.0/tests/e2e/matrix.py +482 -0
- fl_studio_agent-0.1.0/tests/e2e/test_automation_create_minimal_pair.py +26 -0
- fl_studio_agent-0.1.0/tests/e2e/test_bridge_notes_live.py +56 -0
- fl_studio_agent-0.1.0/tests/e2e/test_check_live.py +59 -0
- fl_studio_agent-0.1.0/tests/e2e/test_control_plane_e2e.py +72 -0
- fl_studio_agent-0.1.0/tests/e2e/test_matrix.py +18 -0
- fl_studio_agent-0.1.0/tests/e2e/test_matrix_live.py +59 -0
- fl_studio_agent-0.1.0/tests/e2e/test_matrix_render.py +133 -0
- fl_studio_agent-0.1.0/tests/e2e/test_mixer_minimal_pair.py +83 -0
- fl_studio_agent-0.1.0/tests/e2e/test_plugin_state_minimal_pair.py +20 -0
- fl_studio_agent-0.1.0/tests/e2e/test_provenance.py +61 -0
- fl_studio_agent-0.1.0/tests/e2e/test_roundtrip_fuzz.py +139 -0
- fl_studio_agent-0.1.0/tests/e2e/test_router_generic.py +98 -0
- fl_studio_agent-0.1.0/tests/e2e/test_session_file_lane_e2e.py +24 -0
- fl_studio_agent-0.1.0/tests/evidence/plugin-effect/README.md +13 -0
- fl_studio_agent-0.1.0/tests/evidence/plugin-effect/after-add-parametric-eq2-on-master.flp +0 -0
- fl_studio_agent-0.1.0/tests/evidence/plugin-effect/baseline.flp +0 -0
- fl_studio_agent-0.1.0/tests/fake_flpianoroll.py +75 -0
- fl_studio_agent-0.1.0/tests/fl_env.py +113 -0
- fl_studio_agent-0.1.0/tests/flp_compat.py +58 -0
- fl_studio_agent-0.1.0/tests/fuzz.py +55 -0
- fl_studio_agent-0.1.0/tests/sdk_fakes.py +173 -0
- fl_studio_agent-0.1.0/tests/session_fakes.py +219 -0
- fl_studio_agent-0.1.0/tests/test_actions.py +522 -0
- fl_studio_agent-0.1.0/tests/test_audio.py +157 -0
- fl_studio_agent-0.1.0/tests/test_audio_stream.py +63 -0
- fl_studio_agent-0.1.0/tests/test_bridge_differential.py +130 -0
- fl_studio_agent-0.1.0/tests/test_carrier_spool.py +186 -0
- fl_studio_agent-0.1.0/tests/test_carrier_udp.py +149 -0
- fl_studio_agent-0.1.0/tests/test_carrier_wire.py +485 -0
- fl_studio_agent-0.1.0/tests/test_check.py +255 -0
- fl_studio_agent-0.1.0/tests/test_clean_traces.py +316 -0
- fl_studio_agent-0.1.0/tests/test_codec_sync.py +42 -0
- fl_studio_agent-0.1.0/tests/test_contract.py +140 -0
- fl_studio_agent-0.1.0/tests/test_control_broker.py +260 -0
- fl_studio_agent-0.1.0/tests/test_controller_framing.py +495 -0
- fl_studio_agent-0.1.0/tests/test_dev_session.py +71 -0
- fl_studio_agent-0.1.0/tests/test_discovery.py +382 -0
- fl_studio_agent-0.1.0/tests/test_doctor_cli.py +38 -0
- fl_studio_agent-0.1.0/tests/test_fl_license.py +74 -0
- fl_studio_agent-0.1.0/tests/test_fl_windows.py +78 -0
- fl_studio_agent-0.1.0/tests/test_flp_corpus.py +345 -0
- fl_studio_agent-0.1.0/tests/test_gen_layout.py +173 -0
- fl_studio_agent-0.1.0/tests/test_install_midi.py +250 -0
- fl_studio_agent-0.1.0/tests/test_kernel.py +559 -0
- fl_studio_agent-0.1.0/tests/test_kernel_windows.py +271 -0
- fl_studio_agent-0.1.0/tests/test_no_refuted_ctl_lane.py +38 -0
- fl_studio_agent-0.1.0/tests/test_observer.py +425 -0
- fl_studio_agent-0.1.0/tests/test_platform.py +146 -0
- fl_studio_agent-0.1.0/tests/test_plugin_models.py +93 -0
- fl_studio_agent-0.1.0/tests/test_plugin_state_corpus.py +98 -0
- fl_studio_agent-0.1.0/tests/test_profile.py +200 -0
- fl_studio_agent-0.1.0/tests/test_profile_gate.py +24 -0
- fl_studio_agent-0.1.0/tests/test_pyscript.py +353 -0
- fl_studio_agent-0.1.0/tests/test_render_span.py +141 -0
- fl_studio_agent-0.1.0/tests/test_router_ops.py +224 -0
- fl_studio_agent-0.1.0/tests/test_scene.py +268 -0
- fl_studio_agent-0.1.0/tests/test_scrape_rtti.py +325 -0
- fl_studio_agent-0.1.0/tests/test_sdk.py +450 -0
- fl_studio_agent-0.1.0/tests/test_server.py +362 -0
- fl_studio_agent-0.1.0/tests/test_session.py +18 -0
- fl_studio_agent-0.1.0/tests/test_session_audio_lifecycle.py +232 -0
- fl_studio_agent-0.1.0/tests/test_session_bridge.py +732 -0
- fl_studio_agent-0.1.0/tests/test_session_controls.py +891 -0
- fl_studio_agent-0.1.0/tests/test_session_lifecycle.py +125 -0
- fl_studio_agent-0.1.0/tests/test_session_link.py +407 -0
- fl_studio_agent-0.1.0/tests/test_session_link_hello.py +54 -0
- fl_studio_agent-0.1.0/tests/test_session_notes.py +233 -0
- fl_studio_agent-0.1.0/tests/test_session_state.py +301 -0
- fl_studio_agent-0.1.0/tests/test_store.py +246 -0
- fl_studio_agent-0.1.0/tests/test_template.py +68 -0
- fl_studio_agent-0.1.0/tests/test_theory.py +115 -0
- fl_studio_agent-0.1.0/tests/test_transport.py +195 -0
- fl_studio_agent-0.1.0/uv.lock +1130 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
|
|
9
|
+
# FL Studio SDK - proprietary Image-Line code, fetched not vendored
|
|
10
|
+
native/sdk/
|
|
11
|
+
native/build/
|
|
12
|
+
|
|
13
|
+
# probe runtime artifacts
|
|
14
|
+
|
|
15
|
+
# fetched .flp corpus (third-party test inputs, not vendored)
|
|
16
|
+
mcp-server/tests/corpus/data/
|
|
17
|
+
.env
|
|
18
|
+
.env.*
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Origami Research
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fl-studio-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for controlling FL Studio's piano roll
|
|
5
|
+
Project-URL: Homepage, https://github.com/origami-research/fl-studio-agent
|
|
6
|
+
Project-URL: Repository, https://github.com/origami-research/fl-studio-agent
|
|
7
|
+
Project-URL: Documentation, https://github.com/origami-research/fl-studio-agent/tree/main/docs
|
|
8
|
+
Author: Origami Research
|
|
9
|
+
Maintainer: Origami Research
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Keywords: fl-studio,mcp,music-production,piano-roll
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Artistic Software
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: flpkit==0.8.1
|
|
23
|
+
Requires-Dist: mcp>=2.0.0
|
|
24
|
+
Requires-Dist: numpy>=2.0.0
|
|
25
|
+
Requires-Dist: pip>=24.0
|
|
26
|
+
Requires-Dist: python-rtmidi>=1.5.0
|
|
27
|
+
Requires-Dist: watchdog>=6.0.0
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# FL Studio MCP server
|
|
31
|
+
|
|
32
|
+
This package is the MCP front end for deterministic FL Studio work. It routes
|
|
33
|
+
project data through `flpkit`, live host operations through the native Bridge,
|
|
34
|
+
and live controller operations through the MIDI wire/control broker.
|
|
35
|
+
|
|
36
|
+
## Setup
|
|
37
|
+
|
|
38
|
+
From the repository root, follow [../SETUP.md](../SETUP.md). For direct package
|
|
39
|
+
development:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
cd mcp-server
|
|
43
|
+
uv sync --extra private
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The `private` extra pins the unreleased `flpkit` source for internal work. A
|
|
47
|
+
public installation requires the released `flpkit` package described in the
|
|
48
|
+
OSS launch checklist.
|
|
49
|
+
|
|
50
|
+
Install the FL-side components with `uv run fl-mcp-install`. The native Bridge
|
|
51
|
+
is optional for file-lane work and required for live host operations.
|
|
52
|
+
|
|
53
|
+
Register the server with an MCP client using this environment's Python:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
claude mcp add fl-studio -- /absolute/path/to/mcp-server/.venv/bin/python -m fl_mcp
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Tools and SDK
|
|
60
|
+
|
|
61
|
+
The server exposes `run`, `get_scene`, `check`, `render`, and `analyze`; the
|
|
62
|
+
operator's setup report is the `fl-mcp-doctor` command. The `run` tool docstring is the SDK cheatsheet: it documents
|
|
63
|
+
`fl`, `theory`, `Note`, and each supported operation. `run` executes cells in a
|
|
64
|
+
persistent kernel process with full Python, confined by the OS to its workspace
|
|
65
|
+
on Windows; see [../docs/38-python-kernel.md](../docs/38-python-kernel.md).
|
|
66
|
+
|
|
67
|
+
Use `uv run fl-mcp-dev-session --no-repl` for a disposable live smoke session.
|
|
68
|
+
Run `../scripts/gate.sh` for the repository gate; [../docs/gate.md](../docs/gate.md)
|
|
69
|
+
defines its current checks and test count.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# FL Studio MCP server
|
|
2
|
+
|
|
3
|
+
This package is the MCP front end for deterministic FL Studio work. It routes
|
|
4
|
+
project data through `flpkit`, live host operations through the native Bridge,
|
|
5
|
+
and live controller operations through the MIDI wire/control broker.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
From the repository root, follow [../SETUP.md](../SETUP.md). For direct package
|
|
10
|
+
development:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
cd mcp-server
|
|
14
|
+
uv sync --extra private
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The `private` extra pins the unreleased `flpkit` source for internal work. A
|
|
18
|
+
public installation requires the released `flpkit` package described in the
|
|
19
|
+
OSS launch checklist.
|
|
20
|
+
|
|
21
|
+
Install the FL-side components with `uv run fl-mcp-install`. The native Bridge
|
|
22
|
+
is optional for file-lane work and required for live host operations.
|
|
23
|
+
|
|
24
|
+
Register the server with an MCP client using this environment's Python:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
claude mcp add fl-studio -- /absolute/path/to/mcp-server/.venv/bin/python -m fl_mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Tools and SDK
|
|
31
|
+
|
|
32
|
+
The server exposes `run`, `get_scene`, `check`, `render`, and `analyze`; the
|
|
33
|
+
operator's setup report is the `fl-mcp-doctor` command. The `run` tool docstring is the SDK cheatsheet: it documents
|
|
34
|
+
`fl`, `theory`, `Note`, and each supported operation. `run` executes cells in a
|
|
35
|
+
persistent kernel process with full Python, confined by the OS to its workspace
|
|
36
|
+
on Windows; see [../docs/38-python-kernel.md](../docs/38-python-kernel.md).
|
|
37
|
+
|
|
38
|
+
Use `uv run fl-mcp-dev-session --no-repl` for a disposable live smoke session.
|
|
39
|
+
Run `../scripts/gate.sh` for the repository gate; [../docs/gate.md](../docs/gate.md)
|
|
40
|
+
defines its current checks and test count.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fl-studio-agent"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for controlling FL Studio's piano roll"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"mcp>=2.0.0",
|
|
11
|
+
"python-rtmidi>=1.5.0",
|
|
12
|
+
"watchdog>=6.0.0",
|
|
13
|
+
"numpy>=2.0.0",
|
|
14
|
+
"flpkit==0.8.1",
|
|
15
|
+
# The run kernel's %pip installs with the engine's own interpreter.
|
|
16
|
+
"pip>=24.0",
|
|
17
|
+
]
|
|
18
|
+
keywords = ["fl-studio", "mcp", "music-production", "piano-roll"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 3 - Alpha",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Topic :: Artistic Software",
|
|
29
|
+
]
|
|
30
|
+
authors = [{ name = "Origami Research" }]
|
|
31
|
+
maintainers = [{ name = "Origami Research" }]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/origami-research/fl-studio-agent"
|
|
35
|
+
Repository = "https://github.com/origami-research/fl-studio-agent"
|
|
36
|
+
Documentation = "https://github.com/origami-research/fl-studio-agent/tree/main/docs"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
|
|
40
|
+
[project.scripts]
|
|
41
|
+
fl-mcp = "fl_mcp.server:main"
|
|
42
|
+
fl-mcp-doctor = "fl_mcp.doctor:main"
|
|
43
|
+
fl-mcp-contract = "fl_mcp.contract:main"
|
|
44
|
+
fl-mcp-dev-session = "fl_mcp.dev_session:main"
|
|
45
|
+
fl-mcp-install = "fl_mcp.fl.install:main"
|
|
46
|
+
fl-mcp-control-broker = "fl_mcp.control_broker:main"
|
|
47
|
+
fl-mcp-template = "fl_mcp.template:main"
|
|
48
|
+
fl-mcp-listen = "fl_mcp.audio_stream:main"
|
|
49
|
+
fl-mcp-kernel-sid = "fl_mcp.kernel.windows:print_sid"
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["hatchling"]
|
|
53
|
+
build-backend = "hatchling.build"
|
|
54
|
+
|
|
55
|
+
# Everything the runtime reads ships inside src/fl_mcp (the FL-side scripts in
|
|
56
|
+
# fl_scripts/, the template, profiles), so a wheel built from this tree or from
|
|
57
|
+
# its sdist is identical and self-contained. The one outside file is LICENSE,
|
|
58
|
+
# which only the sdist route (scripts/build-wheel.sh) carries into the wheel.
|
|
59
|
+
[tool.hatch.build.targets.wheel]
|
|
60
|
+
packages = ["src/fl_mcp"]
|
|
61
|
+
artifacts = ["src/fl_mcp/assets/session-template.flp"]
|
|
62
|
+
|
|
63
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
64
|
+
"../LICENSE" = "LICENSE"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
[dependency-groups]
|
|
68
|
+
dev = ["pytest>=8.0", "pyflp>=2.0", "python-rtmidi>=1.5", "flpkit==0.8.1"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 110
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
# Enforce the hygiene the code-quality proposal (docs/19) is built on. TRY003
|
|
75
|
+
# (vanilla raise messages) and PLC0415 (in-function imports) are baselined here
|
|
76
|
+
# and retired in proposal phases 2-3 (they need package unification first).
|
|
77
|
+
select = ["E", "F", "I", "BLE", "TRY", "SIM", "PTH", "TID", "RUF"]
|
|
78
|
+
ignore = ["TRY003", "PLC0415", "E501"] # E402 retired: fl/ quarantine fixed the cross-tree boundary (#80)
|
|
79
|
+
|
|
80
|
+
[tool.ruff.lint.per-file-ignores]
|
|
81
|
+
# Tests may catch broadly / suppress deliberately. RUF059: unpacking a fixture
|
|
82
|
+
# tuple (`session, bridge, control = wired`) and using a subset is idiomatic.
|
|
83
|
+
"tests/**" = ["BLE", "TRY", "SIM105", "RUF059"]
|
|
84
|
+
# E402: the frozen codec is embedded after its host imports.
|
|
85
|
+
# BLE001/TRY004: FL's callback boundaries cannot let host exceptions escape.
|
|
86
|
+
# PTH112: FL's embedded stdlib subset is unverified, so this file adds no pathlib import.
|
|
87
|
+
"src/fl_mcp/fl_scripts/device_FLMCP.py" = ["BLE001", "E402", "PTH112", "TRY004"]
|
|
88
|
+
[tool.pytest.ini_options]
|
|
89
|
+
testpaths = ["tests"]
|
|
90
|
+
markers = ["e2e: requires a deliberate live FL Studio session"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
[pytest]
|
|
2
|
+
# `src` makes `fl_mcp` importable and `tests` holds test-only compatibility
|
|
3
|
+
# helpers. The private live-test harness is intentionally not on this path.
|
|
4
|
+
pythonpath = src tests
|
|
5
|
+
testpaths = tests
|
|
6
|
+
markers =
|
|
7
|
+
e2e: live-FL end-to-end tests; require a real FL Studio and FL_E2E=1 (see tests/e2e)
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""What the engine did during one tool call, as sentences a person can read.
|
|
2
|
+
|
|
3
|
+
Every tool result carries ``actions``: one past-tense sentence per change FL
|
|
4
|
+
confirmed or measurement the engine took during the call, in order, so a UI
|
|
5
|
+
can show "what the assistant did". Sentences are built from what each call
|
|
6
|
+
returned (FL's values, per docs/27), never from what was asked for. A call
|
|
7
|
+
that raised adds nothing, and so does a read.
|
|
8
|
+
|
|
9
|
+
``ActionLog.called`` is the one entry point for FL calls: the run kernel host
|
|
10
|
+
records every call a cell makes over the wire, and the check, render and
|
|
11
|
+
analyze tools record their own call the same way, so a check reads the same
|
|
12
|
+
from either path.
|
|
13
|
+
|
|
14
|
+
Only changes in a row merge, so the list keeps FL's order and every sentence
|
|
15
|
+
is true of the state it describes: three or more changes of one kind in a row
|
|
16
|
+
become one sentence listing every item ("Set volume on 5 tracks: kick 0.60,
|
|
17
|
+
..."), repeated undos become "Undid 4 actions in FL", and an identical
|
|
18
|
+
sentence repeated in a row is counted. Changes are told apart by what they
|
|
19
|
+
touch (channel and pattern), never by a name two channels may share.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
from collections.abc import Callable, Mapping
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from fl_mcp.audio import AudioAnalysis
|
|
32
|
+
from fl_mcp.schemas import (
|
|
33
|
+
AutomationResult,
|
|
34
|
+
ChannelLevels,
|
|
35
|
+
CheckResult,
|
|
36
|
+
PluginParam,
|
|
37
|
+
RenderReceipt,
|
|
38
|
+
WriteResult,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# Sentences are complete: the UI shows one line and expands it on click. This
|
|
44
|
+
# cap only stops a pathological one (thousands of tracks, a runaway name).
|
|
45
|
+
MAX_CHARS = 2000
|
|
46
|
+
# A call lists at most this many actions, then "and N more actions": a cell can
|
|
47
|
+
# loop on fl calls for as long as it likes (their time is not charged to it).
|
|
48
|
+
MAX_ACTIONS = 200
|
|
49
|
+
# This many changes of one kind in a row read as one sentence.
|
|
50
|
+
GROUP_AT = 3
|
|
51
|
+
# Calls that rewrite the saved song and reopen FL to apply it.
|
|
52
|
+
SONG_REWRITES = frozenset({"set_automation", "add_effect"})
|
|
53
|
+
# Calls held inside an unfinished fl.file_edit() block, past which they are only counted.
|
|
54
|
+
_MAX_HELD = 10_000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class _Kind:
|
|
59
|
+
"""How one kind of change reads alone and grouped."""
|
|
60
|
+
|
|
61
|
+
one: Callable[[str, Any], str] # the sentence for one target and its value
|
|
62
|
+
many: str # the grouped sentence's lead: {n} counts targets, {tracks} the tracks they are on
|
|
63
|
+
item: Callable[[str, Any], str] # one target inside the grouped sentence
|
|
64
|
+
adds: bool = False # repeated changes to one target add up (merged notes) instead of the last standing
|
|
65
|
+
group_at: int = GROUP_AT
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _Entry:
|
|
70
|
+
sentence: str
|
|
71
|
+
kind: _Kind | None = None
|
|
72
|
+
key: tuple[Any, ...] = () # entries with the same key in a row group together
|
|
73
|
+
target: tuple[Any, ...] = () # what the change is to, e.g. (channel, pattern)
|
|
74
|
+
label: str = "" # how the target reads
|
|
75
|
+
value: Any = None
|
|
76
|
+
track: int | None = None # the channel it is on, for counting and telling equal names apart
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _Run:
|
|
80
|
+
"""Entries in a row that read as one: one kind of change, or one repeated sentence."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, entry: _Entry) -> None:
|
|
83
|
+
self.entry = entry
|
|
84
|
+
self.count = 1
|
|
85
|
+
self.first = [entry] # enough to list a run too short to group
|
|
86
|
+
self.values: dict[tuple[Any, ...], tuple[str, Any, int | None]] = {
|
|
87
|
+
entry.target: (entry.label, entry.value, entry.track)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
def takes(self, entry: _Entry) -> bool:
|
|
91
|
+
if self.entry.kind is None:
|
|
92
|
+
return entry.kind is None and entry.sentence == self.entry.sentence
|
|
93
|
+
return entry.kind is not None and entry.key == self.entry.key
|
|
94
|
+
|
|
95
|
+
def add(self, entry: _Entry) -> None:
|
|
96
|
+
self.count += 1
|
|
97
|
+
if len(self.first) < GROUP_AT:
|
|
98
|
+
self.first.append(entry)
|
|
99
|
+
kind = self.entry.kind
|
|
100
|
+
if kind is None:
|
|
101
|
+
return
|
|
102
|
+
previous = self.values.get(entry.target)
|
|
103
|
+
value = previous[1] + entry.value if kind.adds and previous is not None else entry.value
|
|
104
|
+
self.values[entry.target] = (entry.label, value, entry.track)
|
|
105
|
+
|
|
106
|
+
def sentences(self) -> list[str]:
|
|
107
|
+
kind = self.entry.kind
|
|
108
|
+
if kind is None:
|
|
109
|
+
return [self.entry.sentence + (f" ({self.count} times)" if self.count > 1 else "")]
|
|
110
|
+
if self.count < kind.group_at:
|
|
111
|
+
return [entry.sentence for entry in self.first]
|
|
112
|
+
if len(self.values) == 1:
|
|
113
|
+
((label, value, _),) = self.values.values()
|
|
114
|
+
return [kind.one(label, value)]
|
|
115
|
+
labels = [label for label, _, _ in self.values.values()]
|
|
116
|
+
items = [
|
|
117
|
+
kind.item(label if labels.count(label) == 1 else f"{label} (channel {track})", value)
|
|
118
|
+
for label, value, track in self.values.values()
|
|
119
|
+
]
|
|
120
|
+
tracks = _count(len({track for _, _, track in self.values.values()}), "track")
|
|
121
|
+
return [kind.many.format(n=len(self.values), tracks=tracks) + ": " + ", ".join(items)]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _count(n: int, noun: str) -> str:
|
|
125
|
+
return f"{n} {noun}" if n == 1 else f"{n} {noun}s"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _level(value: float) -> str:
|
|
129
|
+
return f"{value:.2f}"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _bpm(value: float) -> str:
|
|
133
|
+
return f"{round(value, 3):g}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _where(insert: int) -> str:
|
|
137
|
+
return "the master" if insert == 0 else f"mixer track {insert}"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
_MERGED = _Kind(
|
|
141
|
+
one=lambda t, v: f"Wrote {_count(v, 'note')} to {t}",
|
|
142
|
+
many="Wrote notes to {tracks}",
|
|
143
|
+
item=lambda t, v: f"{t} ({v})",
|
|
144
|
+
adds=True,
|
|
145
|
+
)
|
|
146
|
+
_REPLACED = _Kind(
|
|
147
|
+
one=lambda t, v: f"Replaced the notes on {t} ({_count(v, 'note')})",
|
|
148
|
+
many="Replaced the notes on {tracks}",
|
|
149
|
+
item=lambda t, v: f"{t} ({v})",
|
|
150
|
+
)
|
|
151
|
+
_CLEARED = _Kind(
|
|
152
|
+
one=lambda t, v: f"Cleared the notes on {t}",
|
|
153
|
+
many="Cleared the notes on {tracks}",
|
|
154
|
+
item=lambda t, v: t,
|
|
155
|
+
)
|
|
156
|
+
_VOLUME = _Kind(
|
|
157
|
+
one=lambda t, v: f"Set {t} volume to {v}", many="Set volume on {tracks}", item=lambda t, v: f"{t} {v}"
|
|
158
|
+
)
|
|
159
|
+
_PAN = _Kind(one=lambda t, v: f"Set {t} pan to {v}", many="Set pan on {tracks}", item=lambda t, v: f"{t} {v}")
|
|
160
|
+
_PITCH = _Kind(
|
|
161
|
+
one=lambda t, v: f"Set {t} pitch to {v} semitones", many="Set pitch on {tracks}", item=lambda t, v: f"{t} {v}"
|
|
162
|
+
)
|
|
163
|
+
_TEMPO = _Kind(one=lambda t, v: f"Set tempo to {v} BPM", many="Set tempo", item=lambda t, v: v)
|
|
164
|
+
_UNDO = _Kind(
|
|
165
|
+
one=lambda t, v: "Undid the last action in FL" if v == 1 else f"Undid {v} actions in FL",
|
|
166
|
+
many="Undid",
|
|
167
|
+
item=lambda t, v: t,
|
|
168
|
+
adds=True,
|
|
169
|
+
group_at=2,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _param_kind(track: str) -> _Kind:
|
|
174
|
+
return _Kind(
|
|
175
|
+
one=lambda t, v: f"Set {track} {t} to {v}",
|
|
176
|
+
many=f"Set {{n}} parameters on {track}",
|
|
177
|
+
item=lambda t, v: f"{t} {v}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ActionLog:
|
|
182
|
+
"""The actions of one tool call, in the order FL confirmed them."""
|
|
183
|
+
|
|
184
|
+
def __init__(self, track_name: Callable[[int], str] | None = None) -> None:
|
|
185
|
+
self._track_name = track_name or (lambda channel: f"Channel {channel}")
|
|
186
|
+
self._names: dict[int, str] = {}
|
|
187
|
+
self._runs: list[_Run] = []
|
|
188
|
+
self._more = 0 # actions past MAX_ACTIONS, only counted
|
|
189
|
+
self._held: list[tuple[str, Mapping[str, Any], Any]] = []
|
|
190
|
+
self._held_more = 0
|
|
191
|
+
self._holding = 0
|
|
192
|
+
self._hold_failed = False
|
|
193
|
+
|
|
194
|
+
def called(self, method: str, kwargs: Mapping[str, Any], value: Any) -> None:
|
|
195
|
+
"""Record an FL session call that returned. Never raises: FL already has
|
|
196
|
+
the change, so a sentence that cannot be written is logged, not turned
|
|
197
|
+
into an error for a call that succeeded."""
|
|
198
|
+
if method not in _CALLS:
|
|
199
|
+
return
|
|
200
|
+
if self._holding:
|
|
201
|
+
if len(self._held) < _MAX_HELD:
|
|
202
|
+
self._held.append((method, kwargs, value))
|
|
203
|
+
else:
|
|
204
|
+
self._held_more += 1
|
|
205
|
+
return
|
|
206
|
+
self._describe(method, kwargs, value)
|
|
207
|
+
if method in SONG_REWRITES:
|
|
208
|
+
self._add(_Entry("Reopened FL with the changed song"))
|
|
209
|
+
|
|
210
|
+
def hold(self) -> None:
|
|
211
|
+
"""An fl.file_edit() block opened: its changes reach FL only when it closes."""
|
|
212
|
+
self._holding += 1
|
|
213
|
+
|
|
214
|
+
def release(self, applied: bool) -> None:
|
|
215
|
+
"""A block closed; once the outermost closes and FL reopened with every
|
|
216
|
+
change, they are recorded, in order. A failed close records none of them."""
|
|
217
|
+
self._holding -= 1
|
|
218
|
+
self._hold_failed = self._hold_failed or not applied
|
|
219
|
+
if self._holding:
|
|
220
|
+
return
|
|
221
|
+
held, self._held = self._held, []
|
|
222
|
+
more, self._held_more = self._held_more, 0
|
|
223
|
+
failed, self._hold_failed = self._hold_failed, False
|
|
224
|
+
if failed:
|
|
225
|
+
log.warning("a file_edit block failed to close; its %d changes are not listed", len(held) + more)
|
|
226
|
+
return
|
|
227
|
+
for call in held:
|
|
228
|
+
self._describe(*call)
|
|
229
|
+
self._more += more
|
|
230
|
+
if any(method in SONG_REWRITES for method, _, _ in held):
|
|
231
|
+
self._add(_Entry("Reopened FL with the changed song"))
|
|
232
|
+
|
|
233
|
+
def measured(self, analysis: AudioAnalysis, wav: str) -> None:
|
|
234
|
+
"""Record a measurement of a WAV; ``wav`` names it ("the render", "mix.wav")."""
|
|
235
|
+
self._add(_Entry(_measured(analysis, wav)))
|
|
236
|
+
|
|
237
|
+
def sentences(self) -> list[str]:
|
|
238
|
+
out = [_fit(sentence) for run in self._runs for sentence in run.sentences()]
|
|
239
|
+
more = self._more + max(0, len(out) - MAX_ACTIONS)
|
|
240
|
+
out = out[:MAX_ACTIONS]
|
|
241
|
+
if more:
|
|
242
|
+
out.append(f"and {_count(more, 'more action')}")
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
def _describe(self, method: str, kwargs: Mapping[str, Any], value: Any) -> None:
|
|
246
|
+
try:
|
|
247
|
+
entries = _CALLS[method](self, kwargs, value)
|
|
248
|
+
except Exception:
|
|
249
|
+
log.exception("could not describe the %s call for the tool result's actions", method)
|
|
250
|
+
return
|
|
251
|
+
for entry in entries:
|
|
252
|
+
self._add(entry)
|
|
253
|
+
|
|
254
|
+
def _add(self, entry: _Entry) -> None:
|
|
255
|
+
if self._more: # past the cap, so the last listed run is no longer the latest
|
|
256
|
+
self._more += 1
|
|
257
|
+
elif self._runs and self._runs[-1].takes(entry):
|
|
258
|
+
self._runs[-1].add(entry)
|
|
259
|
+
elif len(self._runs) < MAX_ACTIONS:
|
|
260
|
+
self._runs.append(_Run(entry))
|
|
261
|
+
else:
|
|
262
|
+
self._more += 1
|
|
263
|
+
|
|
264
|
+
# -- one describer per FL session call that changes or measures something ----
|
|
265
|
+
|
|
266
|
+
def _name(self, channel: int) -> str:
|
|
267
|
+
if channel not in self._names:
|
|
268
|
+
self._names[channel] = self._track_name(channel)
|
|
269
|
+
return self._names[channel]
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _notes_target(kwargs: Mapping[str, Any], result: WriteResult) -> tuple[tuple[int, int], str, int]:
|
|
273
|
+
target = kwargs["target"]
|
|
274
|
+
label = result.track if result.pattern == 1 else f"{result.track} in pattern {result.pattern}"
|
|
275
|
+
return (target.channel, target.pattern), label, target.channel
|
|
276
|
+
|
|
277
|
+
def _write(self, kwargs: Mapping[str, Any], result: WriteResult) -> list[_Entry]:
|
|
278
|
+
target, label, track = self._notes_target(kwargs, result)
|
|
279
|
+
if result.mode == "merge":
|
|
280
|
+
kind, value = _MERGED, result.wrote
|
|
281
|
+
else: # FL's count afterwards, which is what the track now has
|
|
282
|
+
kind, value = _REPLACED, result.wrote if result.total is None else result.total
|
|
283
|
+
return [_Entry(kind.one(label, value), kind, ("notes", result.mode), target, label, value, track)]
|
|
284
|
+
|
|
285
|
+
def _clear(self, kwargs: Mapping[str, Any], result: WriteResult) -> list[_Entry]:
|
|
286
|
+
target, label, track = self._notes_target(kwargs, result)
|
|
287
|
+
return [_Entry(_CLEARED.one(label, None), _CLEARED, ("clear",), target, label, None, track)]
|
|
288
|
+
|
|
289
|
+
def _levels(self, kwargs: Mapping[str, Any], result: ChannelLevels) -> list[_Entry]:
|
|
290
|
+
channel = result.channel
|
|
291
|
+
track = self._name(channel)
|
|
292
|
+
pitch = f"{result.pitch_semitones:+d}" if result.pitch_semitones else "0"
|
|
293
|
+
fields = (
|
|
294
|
+
("volume", _VOLUME, _level(result.volume)),
|
|
295
|
+
("pan", _PAN, _level(result.pan)),
|
|
296
|
+
("pitch_semitones", _PITCH, pitch),
|
|
297
|
+
)
|
|
298
|
+
return [
|
|
299
|
+
_Entry(kind.one(track, value), kind, (name,), (channel,), track, value, channel)
|
|
300
|
+
for name, kind, value in fields
|
|
301
|
+
if kwargs.get(name) is not None
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
def _param(self, kwargs: Mapping[str, Any], result: PluginParam) -> list[_Entry]:
|
|
305
|
+
channel = kwargs["channel"]
|
|
306
|
+
track = self._name(channel)
|
|
307
|
+
value = _level(result.value) if result.kind == "continuous" or not result.display else result.display
|
|
308
|
+
kind = _param_kind(track)
|
|
309
|
+
return [_Entry(kind.one(result.name, value), kind, ("param", channel), (result.index,), result.name, value, channel)]
|
|
310
|
+
|
|
311
|
+
def _tempo(self, kwargs: Mapping[str, Any], bpm: float) -> list[_Entry]:
|
|
312
|
+
return [_Entry(_TEMPO.one("tempo", _bpm(bpm)), _TEMPO, ("tempo",), ("tempo",), "tempo", _bpm(bpm))]
|
|
313
|
+
|
|
314
|
+
def _automation(self, kwargs: Mapping[str, Any], result: AutomationResult) -> list[_Entry]:
|
|
315
|
+
track = self._name(result.channel)
|
|
316
|
+
return [_Entry(f"Replaced the automation on {track} ({_count(len(result.points), 'point')})")]
|
|
317
|
+
|
|
318
|
+
def _effect(self, kwargs: Mapping[str, Any], effects: tuple[Any, ...]) -> list[_Entry]:
|
|
319
|
+
# The names are FL's readback of that insert after the change.
|
|
320
|
+
names = [effect.name for effect in effects]
|
|
321
|
+
where = _where(kwargs["insert_index"])
|
|
322
|
+
if kwargs["plugin_name"] in names:
|
|
323
|
+
return [_Entry(f"Added {kwargs['plugin_name']} to {where}")]
|
|
324
|
+
return [_Entry(f"Changed the effects on {where}; it now has {', '.join(names) or 'none'}")]
|
|
325
|
+
|
|
326
|
+
def _check(self, kwargs: Mapping[str, Any], result: CheckResult) -> list[_Entry]:
|
|
327
|
+
return [_Entry(_checked(result))]
|
|
328
|
+
|
|
329
|
+
def _render(self, kwargs: Mapping[str, Any], receipt: RenderReceipt) -> list[_Entry]:
|
|
330
|
+
played = _duration(receipt.span.seconds)
|
|
331
|
+
if receipt.span.source == "pattern" and receipt.span.pattern is not None:
|
|
332
|
+
return [_Entry(f"Rendered {played} of pattern {receipt.span.pattern}")]
|
|
333
|
+
return [_Entry(f"Rendered {played}")]
|
|
334
|
+
|
|
335
|
+
def _export(self, kwargs: Mapping[str, Any], path: Path) -> list[_Entry]:
|
|
336
|
+
return [_Entry(f"Exported the song to {Path(path).name}")]
|
|
337
|
+
|
|
338
|
+
def _save(self, kwargs: Mapping[str, Any], value: Any) -> list[_Entry]:
|
|
339
|
+
return [_Entry("Saved the song")]
|
|
340
|
+
|
|
341
|
+
def _undo(self, kwargs: Mapping[str, Any], value: Any) -> list[_Entry]:
|
|
342
|
+
return [_Entry(_UNDO.one("", 1), _UNDO, ("undo",), ("undo",), "", 1)]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_CALLS: dict[str, Callable[[ActionLog, Mapping[str, Any], Any], list[_Entry]]] = {
|
|
346
|
+
"write_notes": ActionLog._write,
|
|
347
|
+
"clear_notes": ActionLog._clear,
|
|
348
|
+
"set_channel_levels": ActionLog._levels,
|
|
349
|
+
"set_plugin_param": ActionLog._param,
|
|
350
|
+
"set_tempo": ActionLog._tempo,
|
|
351
|
+
"set_automation": ActionLog._automation,
|
|
352
|
+
"add_effect": ActionLog._effect,
|
|
353
|
+
"check": ActionLog._check,
|
|
354
|
+
"render_wav": ActionLog._render,
|
|
355
|
+
"export_offline": ActionLog._export,
|
|
356
|
+
"save_project": ActionLog._save,
|
|
357
|
+
"undo": ActionLog._undo,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _checked(result: CheckResult) -> str:
|
|
362
|
+
last = result.start_bar + result.bars - 1
|
|
363
|
+
span = f"bar {result.start_bar}" if result.bars == 1 else f"bars {result.start_bar}-{last}"
|
|
364
|
+
heard = sum(1 for c in result.channels if c.sounded)
|
|
365
|
+
shared = sum(1 for c in result.channels if c.sounded is None)
|
|
366
|
+
parts = [f"{heard} of {len(result.channels)} tracks sound"]
|
|
367
|
+
if shared:
|
|
368
|
+
parts.append(f"{shared} not measurable alone")
|
|
369
|
+
master = result.master_peak_dbfs
|
|
370
|
+
parts.append(f"master {master:.1f} dBFS" if master is not None else "master silent")
|
|
371
|
+
return f"Checked {span}: " + ", ".join(parts)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _measured(analysis: AudioAnalysis, wav: str) -> str:
|
|
375
|
+
if not math.isfinite(analysis.true_peak_dbfs):
|
|
376
|
+
return f"Measured {wav}: it is silent"
|
|
377
|
+
parts = [f"peak {analysis.true_peak_dbfs:.1f} dBFS"]
|
|
378
|
+
if math.isfinite(analysis.integrated_lufs):
|
|
379
|
+
parts.append(f"{analysis.integrated_lufs:.1f} LUFS")
|
|
380
|
+
balance = analysis.spectral_balance
|
|
381
|
+
if math.isfinite(balance.low_dbfs) and math.isfinite(balance.mid_dbfs):
|
|
382
|
+
lows = round(balance.low_dbfs - balance.mid_dbfs)
|
|
383
|
+
parts.append(
|
|
384
|
+
f"lows {lows} dB over mids" if lows > 0 else f"lows {-lows} dB under mids" if lows < 0 else "lows level with mids"
|
|
385
|
+
)
|
|
386
|
+
if analysis.bpm is not None:
|
|
387
|
+
parts.append(f"{analysis.bpm:.0f} BPM")
|
|
388
|
+
if analysis.key is not None:
|
|
389
|
+
parts.append(f"key {analysis.key}")
|
|
390
|
+
return f"Measured {wav}: " + ", ".join(parts)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _duration(seconds: float) -> str:
|
|
394
|
+
whole = round(seconds)
|
|
395
|
+
return f"{whole // 60}:{whole % 60:02d}"
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _fit(sentence: str) -> str:
|
|
399
|
+
return sentence if len(sentence) <= MAX_CHARS else sentence[: MAX_CHARS - 3].rstrip() + "..."
|
|
Binary file
|