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.
Files changed (89) hide show
  1. fl_mcp/__init__.py +5 -0
  2. fl_mcp/__main__.py +6 -0
  3. fl_mcp/actions.py +399 -0
  4. fl_mcp/assets/session-template.flp +0 -0
  5. fl_mcp/audio.py +350 -0
  6. fl_mcp/audio_stream.py +191 -0
  7. fl_mcp/clock.py +16 -0
  8. fl_mcp/contract.py +264 -0
  9. fl_mcp/control_broker.py +6 -0
  10. fl_mcp/controller_version.py +34 -0
  11. fl_mcp/core/__init__.py +3 -0
  12. fl_mcp/core/carriers/__init__.py +44 -0
  13. fl_mcp/core/carriers/broker_client.py +150 -0
  14. fl_mcp/core/carriers/control_broker.py +219 -0
  15. fl_mcp/core/carriers/spool.py +229 -0
  16. fl_mcp/core/carriers/udp.py +286 -0
  17. fl_mcp/core/carriers/wire.py +332 -0
  18. fl_mcp/core/discovery.py +223 -0
  19. fl_mcp/core/provenance.py +76 -0
  20. fl_mcp/core/router.py +247 -0
  21. fl_mcp/core/transport.py +95 -0
  22. fl_mcp/dev_session.py +176 -0
  23. fl_mcp/discover.py +225 -0
  24. fl_mcp/doctor.py +24 -0
  25. fl_mcp/errors.py +247 -0
  26. fl_mcp/fl/__init__.py +7 -0
  27. fl_mcp/fl/install.py +363 -0
  28. fl_mcp/fl/paths.py +124 -0
  29. fl_mcp/fl/process.py +266 -0
  30. fl_mcp/fl_scripts/MCP Write Notes.pyscript +396 -0
  31. fl_mcp/fl_scripts/__init__.py +7 -0
  32. fl_mcp/fl_scripts/device_FLMCP.py +613 -0
  33. fl_mcp/fl_scripts/flmcp_codec.py +122 -0
  34. fl_mcp/flp.py +102 -0
  35. fl_mcp/gen_layout.py +177 -0
  36. fl_mcp/kernel/__init__.py +6 -0
  37. fl_mcp/kernel/cell.py +157 -0
  38. fl_mcp/kernel/child.py +208 -0
  39. fl_mcp/kernel/host.py +397 -0
  40. fl_mcp/kernel/launch.py +190 -0
  41. fl_mcp/kernel/private_dirs.py +28 -0
  42. fl_mcp/kernel/site/sitecustomize.py +12 -0
  43. fl_mcp/kernel/windows.py +542 -0
  44. fl_mcp/kernel/wire.py +189 -0
  45. fl_mcp/make_agent_project.py +55 -0
  46. fl_mcp/observer.py +420 -0
  47. fl_mcp/ops/__init__.py +10 -0
  48. fl_mcp/ops/automation.py +34 -0
  49. fl_mcp/ops/levels.py +33 -0
  50. fl_mcp/ops/mixer.py +34 -0
  51. fl_mcp/ops/notes.py +52 -0
  52. fl_mcp/ops/params.py +34 -0
  53. fl_mcp/ops/playlist.py +36 -0
  54. fl_mcp/passes/__init__.py +45 -0
  55. fl_mcp/passes/getters.py +55 -0
  56. fl_mcp/passes/map_align.py +149 -0
  57. fl_mcp/passes/plugins.py +151 -0
  58. fl_mcp/passes/sweep.py +67 -0
  59. fl_mcp/passes/target.py +42 -0
  60. fl_mcp/passes/timing.py +42 -0
  61. fl_mcp/pitch.py +68 -0
  62. fl_mcp/platform/__init__.py +29 -0
  63. fl_mcp/platform/runtime.py +245 -0
  64. fl_mcp/plugin_models/README.md +11 -0
  65. fl_mcp/plugin_models/__init__.py +24 -0
  66. fl_mcp/profile.py +644 -0
  67. fl_mcp/profiles/fl-26.1.5-5384-arm64.json +273 -0
  68. fl_mcp/scene.py +408 -0
  69. fl_mcp/schemas.py +497 -0
  70. fl_mcp/scrape_rtti.py +470 -0
  71. fl_mcp/sdk.py +293 -0
  72. fl_mcp/server.py +362 -0
  73. fl_mcp/session/__init__.py +28 -0
  74. fl_mcp/session/check.py +283 -0
  75. fl_mcp/session/controls.py +679 -0
  76. fl_mcp/session/detect.py +287 -0
  77. fl_mcp/session/facade.py +631 -0
  78. fl_mcp/session/lifecycle.py +66 -0
  79. fl_mcp/session/link.py +283 -0
  80. fl_mcp/session/notes.py +360 -0
  81. fl_mcp/session/state.py +319 -0
  82. fl_mcp/store.py +184 -0
  83. fl_mcp/template.py +24 -0
  84. fl_mcp/theory.py +113 -0
  85. fl_studio_agent-0.1.0.dist-info/METADATA +70 -0
  86. fl_studio_agent-0.1.0.dist-info/RECORD +89 -0
  87. fl_studio_agent-0.1.0.dist-info/WHEEL +4 -0
  88. fl_studio_agent-0.1.0.dist-info/entry_points.txt +10 -0
  89. fl_studio_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
fl_mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """FL Studio MCP server."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("fl-studio-agent")
fl_mcp/__main__.py ADDED
@@ -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()
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