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.
Files changed (172) hide show
  1. fl_studio_agent-0.1.0/.gitignore +18 -0
  2. fl_studio_agent-0.1.0/LICENSE +21 -0
  3. fl_studio_agent-0.1.0/PKG-INFO +69 -0
  4. fl_studio_agent-0.1.0/README.md +40 -0
  5. fl_studio_agent-0.1.0/pyproject.toml +90 -0
  6. fl_studio_agent-0.1.0/pytest.ini +7 -0
  7. fl_studio_agent-0.1.0/src/fl_mcp/__init__.py +5 -0
  8. fl_studio_agent-0.1.0/src/fl_mcp/__main__.py +6 -0
  9. fl_studio_agent-0.1.0/src/fl_mcp/actions.py +399 -0
  10. fl_studio_agent-0.1.0/src/fl_mcp/assets/session-template.flp +0 -0
  11. fl_studio_agent-0.1.0/src/fl_mcp/audio.py +350 -0
  12. fl_studio_agent-0.1.0/src/fl_mcp/audio_stream.py +191 -0
  13. fl_studio_agent-0.1.0/src/fl_mcp/clock.py +16 -0
  14. fl_studio_agent-0.1.0/src/fl_mcp/contract.py +264 -0
  15. fl_studio_agent-0.1.0/src/fl_mcp/control_broker.py +6 -0
  16. fl_studio_agent-0.1.0/src/fl_mcp/controller_version.py +34 -0
  17. fl_studio_agent-0.1.0/src/fl_mcp/core/__init__.py +3 -0
  18. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/__init__.py +44 -0
  19. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/broker_client.py +150 -0
  20. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/control_broker.py +219 -0
  21. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/spool.py +229 -0
  22. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/udp.py +286 -0
  23. fl_studio_agent-0.1.0/src/fl_mcp/core/carriers/wire.py +332 -0
  24. fl_studio_agent-0.1.0/src/fl_mcp/core/discovery.py +223 -0
  25. fl_studio_agent-0.1.0/src/fl_mcp/core/provenance.py +76 -0
  26. fl_studio_agent-0.1.0/src/fl_mcp/core/router.py +247 -0
  27. fl_studio_agent-0.1.0/src/fl_mcp/core/transport.py +95 -0
  28. fl_studio_agent-0.1.0/src/fl_mcp/dev_session.py +176 -0
  29. fl_studio_agent-0.1.0/src/fl_mcp/discover.py +225 -0
  30. fl_studio_agent-0.1.0/src/fl_mcp/doctor.py +24 -0
  31. fl_studio_agent-0.1.0/src/fl_mcp/errors.py +247 -0
  32. fl_studio_agent-0.1.0/src/fl_mcp/fl/__init__.py +7 -0
  33. fl_studio_agent-0.1.0/src/fl_mcp/fl/install.py +363 -0
  34. fl_studio_agent-0.1.0/src/fl_mcp/fl/paths.py +124 -0
  35. fl_studio_agent-0.1.0/src/fl_mcp/fl/process.py +266 -0
  36. fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/MCP Write Notes.pyscript +396 -0
  37. fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/__init__.py +7 -0
  38. fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/device_FLMCP.py +613 -0
  39. fl_studio_agent-0.1.0/src/fl_mcp/fl_scripts/flmcp_codec.py +122 -0
  40. fl_studio_agent-0.1.0/src/fl_mcp/flp.py +102 -0
  41. fl_studio_agent-0.1.0/src/fl_mcp/gen_layout.py +177 -0
  42. fl_studio_agent-0.1.0/src/fl_mcp/kernel/__init__.py +6 -0
  43. fl_studio_agent-0.1.0/src/fl_mcp/kernel/cell.py +157 -0
  44. fl_studio_agent-0.1.0/src/fl_mcp/kernel/child.py +208 -0
  45. fl_studio_agent-0.1.0/src/fl_mcp/kernel/host.py +397 -0
  46. fl_studio_agent-0.1.0/src/fl_mcp/kernel/launch.py +190 -0
  47. fl_studio_agent-0.1.0/src/fl_mcp/kernel/private_dirs.py +28 -0
  48. fl_studio_agent-0.1.0/src/fl_mcp/kernel/site/sitecustomize.py +12 -0
  49. fl_studio_agent-0.1.0/src/fl_mcp/kernel/windows.py +542 -0
  50. fl_studio_agent-0.1.0/src/fl_mcp/kernel/wire.py +189 -0
  51. fl_studio_agent-0.1.0/src/fl_mcp/make_agent_project.py +55 -0
  52. fl_studio_agent-0.1.0/src/fl_mcp/observer.py +420 -0
  53. fl_studio_agent-0.1.0/src/fl_mcp/ops/__init__.py +10 -0
  54. fl_studio_agent-0.1.0/src/fl_mcp/ops/automation.py +34 -0
  55. fl_studio_agent-0.1.0/src/fl_mcp/ops/levels.py +33 -0
  56. fl_studio_agent-0.1.0/src/fl_mcp/ops/mixer.py +34 -0
  57. fl_studio_agent-0.1.0/src/fl_mcp/ops/notes.py +52 -0
  58. fl_studio_agent-0.1.0/src/fl_mcp/ops/params.py +34 -0
  59. fl_studio_agent-0.1.0/src/fl_mcp/ops/playlist.py +36 -0
  60. fl_studio_agent-0.1.0/src/fl_mcp/passes/__init__.py +45 -0
  61. fl_studio_agent-0.1.0/src/fl_mcp/passes/getters.py +55 -0
  62. fl_studio_agent-0.1.0/src/fl_mcp/passes/map_align.py +149 -0
  63. fl_studio_agent-0.1.0/src/fl_mcp/passes/plugins.py +151 -0
  64. fl_studio_agent-0.1.0/src/fl_mcp/passes/sweep.py +67 -0
  65. fl_studio_agent-0.1.0/src/fl_mcp/passes/target.py +42 -0
  66. fl_studio_agent-0.1.0/src/fl_mcp/passes/timing.py +42 -0
  67. fl_studio_agent-0.1.0/src/fl_mcp/pitch.py +68 -0
  68. fl_studio_agent-0.1.0/src/fl_mcp/platform/__init__.py +29 -0
  69. fl_studio_agent-0.1.0/src/fl_mcp/platform/runtime.py +245 -0
  70. fl_studio_agent-0.1.0/src/fl_mcp/plugin_models/README.md +11 -0
  71. fl_studio_agent-0.1.0/src/fl_mcp/plugin_models/__init__.py +24 -0
  72. fl_studio_agent-0.1.0/src/fl_mcp/profile.py +644 -0
  73. fl_studio_agent-0.1.0/src/fl_mcp/profiles/fl-26.1.5-5384-arm64.json +273 -0
  74. fl_studio_agent-0.1.0/src/fl_mcp/scene.py +408 -0
  75. fl_studio_agent-0.1.0/src/fl_mcp/schemas.py +497 -0
  76. fl_studio_agent-0.1.0/src/fl_mcp/scrape_rtti.py +470 -0
  77. fl_studio_agent-0.1.0/src/fl_mcp/sdk.py +293 -0
  78. fl_studio_agent-0.1.0/src/fl_mcp/server.py +362 -0
  79. fl_studio_agent-0.1.0/src/fl_mcp/session/__init__.py +28 -0
  80. fl_studio_agent-0.1.0/src/fl_mcp/session/check.py +283 -0
  81. fl_studio_agent-0.1.0/src/fl_mcp/session/controls.py +679 -0
  82. fl_studio_agent-0.1.0/src/fl_mcp/session/detect.py +287 -0
  83. fl_studio_agent-0.1.0/src/fl_mcp/session/facade.py +631 -0
  84. fl_studio_agent-0.1.0/src/fl_mcp/session/lifecycle.py +66 -0
  85. fl_studio_agent-0.1.0/src/fl_mcp/session/link.py +283 -0
  86. fl_studio_agent-0.1.0/src/fl_mcp/session/notes.py +360 -0
  87. fl_studio_agent-0.1.0/src/fl_mcp/session/state.py +319 -0
  88. fl_studio_agent-0.1.0/src/fl_mcp/store.py +184 -0
  89. fl_studio_agent-0.1.0/src/fl_mcp/template.py +24 -0
  90. fl_studio_agent-0.1.0/src/fl_mcp/theory.py +113 -0
  91. fl_studio_agent-0.1.0/tests/carrier_fakes.py +99 -0
  92. fl_studio_agent-0.1.0/tests/conftest.py +36 -0
  93. fl_studio_agent-0.1.0/tests/corpus/__init__.py +0 -0
  94. fl_studio_agent-0.1.0/tests/corpus/fetch.py +31 -0
  95. fl_studio_agent-0.1.0/tests/corpus/manifest.py +38 -0
  96. fl_studio_agent-0.1.0/tests/e2e/__init__.py +0 -0
  97. fl_studio_agent-0.1.0/tests/e2e/conftest.py +64 -0
  98. fl_studio_agent-0.1.0/tests/e2e/fl_host.py +114 -0
  99. fl_studio_agent-0.1.0/tests/e2e/matrix.py +482 -0
  100. fl_studio_agent-0.1.0/tests/e2e/test_automation_create_minimal_pair.py +26 -0
  101. fl_studio_agent-0.1.0/tests/e2e/test_bridge_notes_live.py +56 -0
  102. fl_studio_agent-0.1.0/tests/e2e/test_check_live.py +59 -0
  103. fl_studio_agent-0.1.0/tests/e2e/test_control_plane_e2e.py +72 -0
  104. fl_studio_agent-0.1.0/tests/e2e/test_matrix.py +18 -0
  105. fl_studio_agent-0.1.0/tests/e2e/test_matrix_live.py +59 -0
  106. fl_studio_agent-0.1.0/tests/e2e/test_matrix_render.py +133 -0
  107. fl_studio_agent-0.1.0/tests/e2e/test_mixer_minimal_pair.py +83 -0
  108. fl_studio_agent-0.1.0/tests/e2e/test_plugin_state_minimal_pair.py +20 -0
  109. fl_studio_agent-0.1.0/tests/e2e/test_provenance.py +61 -0
  110. fl_studio_agent-0.1.0/tests/e2e/test_roundtrip_fuzz.py +139 -0
  111. fl_studio_agent-0.1.0/tests/e2e/test_router_generic.py +98 -0
  112. fl_studio_agent-0.1.0/tests/e2e/test_session_file_lane_e2e.py +24 -0
  113. fl_studio_agent-0.1.0/tests/evidence/plugin-effect/README.md +13 -0
  114. fl_studio_agent-0.1.0/tests/evidence/plugin-effect/after-add-parametric-eq2-on-master.flp +0 -0
  115. fl_studio_agent-0.1.0/tests/evidence/plugin-effect/baseline.flp +0 -0
  116. fl_studio_agent-0.1.0/tests/fake_flpianoroll.py +75 -0
  117. fl_studio_agent-0.1.0/tests/fl_env.py +113 -0
  118. fl_studio_agent-0.1.0/tests/flp_compat.py +58 -0
  119. fl_studio_agent-0.1.0/tests/fuzz.py +55 -0
  120. fl_studio_agent-0.1.0/tests/sdk_fakes.py +173 -0
  121. fl_studio_agent-0.1.0/tests/session_fakes.py +219 -0
  122. fl_studio_agent-0.1.0/tests/test_actions.py +522 -0
  123. fl_studio_agent-0.1.0/tests/test_audio.py +157 -0
  124. fl_studio_agent-0.1.0/tests/test_audio_stream.py +63 -0
  125. fl_studio_agent-0.1.0/tests/test_bridge_differential.py +130 -0
  126. fl_studio_agent-0.1.0/tests/test_carrier_spool.py +186 -0
  127. fl_studio_agent-0.1.0/tests/test_carrier_udp.py +149 -0
  128. fl_studio_agent-0.1.0/tests/test_carrier_wire.py +485 -0
  129. fl_studio_agent-0.1.0/tests/test_check.py +255 -0
  130. fl_studio_agent-0.1.0/tests/test_clean_traces.py +316 -0
  131. fl_studio_agent-0.1.0/tests/test_codec_sync.py +42 -0
  132. fl_studio_agent-0.1.0/tests/test_contract.py +140 -0
  133. fl_studio_agent-0.1.0/tests/test_control_broker.py +260 -0
  134. fl_studio_agent-0.1.0/tests/test_controller_framing.py +495 -0
  135. fl_studio_agent-0.1.0/tests/test_dev_session.py +71 -0
  136. fl_studio_agent-0.1.0/tests/test_discovery.py +382 -0
  137. fl_studio_agent-0.1.0/tests/test_doctor_cli.py +38 -0
  138. fl_studio_agent-0.1.0/tests/test_fl_license.py +74 -0
  139. fl_studio_agent-0.1.0/tests/test_fl_windows.py +78 -0
  140. fl_studio_agent-0.1.0/tests/test_flp_corpus.py +345 -0
  141. fl_studio_agent-0.1.0/tests/test_gen_layout.py +173 -0
  142. fl_studio_agent-0.1.0/tests/test_install_midi.py +250 -0
  143. fl_studio_agent-0.1.0/tests/test_kernel.py +559 -0
  144. fl_studio_agent-0.1.0/tests/test_kernel_windows.py +271 -0
  145. fl_studio_agent-0.1.0/tests/test_no_refuted_ctl_lane.py +38 -0
  146. fl_studio_agent-0.1.0/tests/test_observer.py +425 -0
  147. fl_studio_agent-0.1.0/tests/test_platform.py +146 -0
  148. fl_studio_agent-0.1.0/tests/test_plugin_models.py +93 -0
  149. fl_studio_agent-0.1.0/tests/test_plugin_state_corpus.py +98 -0
  150. fl_studio_agent-0.1.0/tests/test_profile.py +200 -0
  151. fl_studio_agent-0.1.0/tests/test_profile_gate.py +24 -0
  152. fl_studio_agent-0.1.0/tests/test_pyscript.py +353 -0
  153. fl_studio_agent-0.1.0/tests/test_render_span.py +141 -0
  154. fl_studio_agent-0.1.0/tests/test_router_ops.py +224 -0
  155. fl_studio_agent-0.1.0/tests/test_scene.py +268 -0
  156. fl_studio_agent-0.1.0/tests/test_scrape_rtti.py +325 -0
  157. fl_studio_agent-0.1.0/tests/test_sdk.py +450 -0
  158. fl_studio_agent-0.1.0/tests/test_server.py +362 -0
  159. fl_studio_agent-0.1.0/tests/test_session.py +18 -0
  160. fl_studio_agent-0.1.0/tests/test_session_audio_lifecycle.py +232 -0
  161. fl_studio_agent-0.1.0/tests/test_session_bridge.py +732 -0
  162. fl_studio_agent-0.1.0/tests/test_session_controls.py +891 -0
  163. fl_studio_agent-0.1.0/tests/test_session_lifecycle.py +125 -0
  164. fl_studio_agent-0.1.0/tests/test_session_link.py +407 -0
  165. fl_studio_agent-0.1.0/tests/test_session_link_hello.py +54 -0
  166. fl_studio_agent-0.1.0/tests/test_session_notes.py +233 -0
  167. fl_studio_agent-0.1.0/tests/test_session_state.py +301 -0
  168. fl_studio_agent-0.1.0/tests/test_store.py +246 -0
  169. fl_studio_agent-0.1.0/tests/test_template.py +68 -0
  170. fl_studio_agent-0.1.0/tests/test_theory.py +115 -0
  171. fl_studio_agent-0.1.0/tests/test_transport.py +195 -0
  172. 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,5 @@
1
+ """FL Studio MCP server."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("fl-studio-agent")
@@ -0,0 +1,6 @@
1
+ """Entry point: `python -m fl_mcp` runs the server, `python -m fl_mcp.fl.install` installs."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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() + "..."