fl-studio-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fl_mcp/__init__.py +5 -0
- fl_mcp/__main__.py +6 -0
- fl_mcp/actions.py +399 -0
- fl_mcp/assets/session-template.flp +0 -0
- fl_mcp/audio.py +350 -0
- fl_mcp/audio_stream.py +191 -0
- fl_mcp/clock.py +16 -0
- fl_mcp/contract.py +264 -0
- fl_mcp/control_broker.py +6 -0
- fl_mcp/controller_version.py +34 -0
- fl_mcp/core/__init__.py +3 -0
- fl_mcp/core/carriers/__init__.py +44 -0
- fl_mcp/core/carriers/broker_client.py +150 -0
- fl_mcp/core/carriers/control_broker.py +219 -0
- fl_mcp/core/carriers/spool.py +229 -0
- fl_mcp/core/carriers/udp.py +286 -0
- fl_mcp/core/carriers/wire.py +332 -0
- fl_mcp/core/discovery.py +223 -0
- fl_mcp/core/provenance.py +76 -0
- fl_mcp/core/router.py +247 -0
- fl_mcp/core/transport.py +95 -0
- fl_mcp/dev_session.py +176 -0
- fl_mcp/discover.py +225 -0
- fl_mcp/doctor.py +24 -0
- fl_mcp/errors.py +247 -0
- fl_mcp/fl/__init__.py +7 -0
- fl_mcp/fl/install.py +363 -0
- fl_mcp/fl/paths.py +124 -0
- fl_mcp/fl/process.py +266 -0
- fl_mcp/fl_scripts/MCP Write Notes.pyscript +396 -0
- fl_mcp/fl_scripts/__init__.py +7 -0
- fl_mcp/fl_scripts/device_FLMCP.py +613 -0
- fl_mcp/fl_scripts/flmcp_codec.py +122 -0
- fl_mcp/flp.py +102 -0
- fl_mcp/gen_layout.py +177 -0
- fl_mcp/kernel/__init__.py +6 -0
- fl_mcp/kernel/cell.py +157 -0
- fl_mcp/kernel/child.py +208 -0
- fl_mcp/kernel/host.py +397 -0
- fl_mcp/kernel/launch.py +190 -0
- fl_mcp/kernel/private_dirs.py +28 -0
- fl_mcp/kernel/site/sitecustomize.py +12 -0
- fl_mcp/kernel/windows.py +542 -0
- fl_mcp/kernel/wire.py +189 -0
- fl_mcp/make_agent_project.py +55 -0
- fl_mcp/observer.py +420 -0
- fl_mcp/ops/__init__.py +10 -0
- fl_mcp/ops/automation.py +34 -0
- fl_mcp/ops/levels.py +33 -0
- fl_mcp/ops/mixer.py +34 -0
- fl_mcp/ops/notes.py +52 -0
- fl_mcp/ops/params.py +34 -0
- fl_mcp/ops/playlist.py +36 -0
- fl_mcp/passes/__init__.py +45 -0
- fl_mcp/passes/getters.py +55 -0
- fl_mcp/passes/map_align.py +149 -0
- fl_mcp/passes/plugins.py +151 -0
- fl_mcp/passes/sweep.py +67 -0
- fl_mcp/passes/target.py +42 -0
- fl_mcp/passes/timing.py +42 -0
- fl_mcp/pitch.py +68 -0
- fl_mcp/platform/__init__.py +29 -0
- fl_mcp/platform/runtime.py +245 -0
- fl_mcp/plugin_models/README.md +11 -0
- fl_mcp/plugin_models/__init__.py +24 -0
- fl_mcp/profile.py +644 -0
- fl_mcp/profiles/fl-26.1.5-5384-arm64.json +273 -0
- fl_mcp/scene.py +408 -0
- fl_mcp/schemas.py +497 -0
- fl_mcp/scrape_rtti.py +470 -0
- fl_mcp/sdk.py +293 -0
- fl_mcp/server.py +362 -0
- fl_mcp/session/__init__.py +28 -0
- fl_mcp/session/check.py +283 -0
- fl_mcp/session/controls.py +679 -0
- fl_mcp/session/detect.py +287 -0
- fl_mcp/session/facade.py +631 -0
- fl_mcp/session/lifecycle.py +66 -0
- fl_mcp/session/link.py +283 -0
- fl_mcp/session/notes.py +360 -0
- fl_mcp/session/state.py +319 -0
- fl_mcp/store.py +184 -0
- fl_mcp/template.py +24 -0
- fl_mcp/theory.py +113 -0
- fl_studio_agent-0.1.0.dist-info/METADATA +70 -0
- fl_studio_agent-0.1.0.dist-info/RECORD +89 -0
- fl_studio_agent-0.1.0.dist-info/WHEEL +4 -0
- fl_studio_agent-0.1.0.dist-info/entry_points.txt +10 -0
- fl_studio_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
fl_mcp/__init__.py
ADDED
fl_mcp/__main__.py
ADDED
fl_mcp/actions.py
ADDED
|
@@ -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
|