mcp-flstudio 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 (82) hide show
  1. fl_studio_mcp/__init__.py +3 -0
  2. fl_studio_mcp/audio/__init__.py +1 -0
  3. fl_studio_mcp/audio/analysis.py +169 -0
  4. fl_studio_mcp/audio/slicer.py +283 -0
  5. fl_studio_mcp/dispatcher.py +386 -0
  6. fl_studio_mcp/pilot_a6.py +302 -0
  7. fl_studio_mcp/pilot_a6_run.py +99 -0
  8. fl_studio_mcp/preset_paths.py +87 -0
  9. fl_studio_mcp/safety.py +437 -0
  10. fl_studio_mcp/server.py +285 -0
  11. fl_studio_mcp/tools/__init__.py +29 -0
  12. fl_studio_mcp/tools/audio_v2.py +287 -0
  13. fl_studio_mcp/tools/batch.py +234 -0
  14. fl_studio_mcp/tools/channel_v2.py +384 -0
  15. fl_studio_mcp/tools/channels.py +454 -0
  16. fl_studio_mcp/tools/delete.py +194 -0
  17. fl_studio_mcp/tools/edison_v2.py +161 -0
  18. fl_studio_mcp/tools/exec_python.py +153 -0
  19. fl_studio_mcp/tools/file_v2.py +152 -0
  20. fl_studio_mcp/tools/genre_v2.py +306 -0
  21. fl_studio_mcp/tools/help_v2.py +369 -0
  22. fl_studio_mcp/tools/launchpad.py +418 -0
  23. fl_studio_mcp/tools/lifecycle_v2.py +757 -0
  24. fl_studio_mcp/tools/markers.py +91 -0
  25. fl_studio_mcp/tools/mixer.py +294 -0
  26. fl_studio_mcp/tools/mixer_tracks.py +199 -0
  27. fl_studio_mcp/tools/mixer_v2.py +723 -0
  28. fl_studio_mcp/tools/notes_v2.py +507 -0
  29. fl_studio_mcp/tools/pattern_v2.py +417 -0
  30. fl_studio_mcp/tools/patterns.py +83 -0
  31. fl_studio_mcp/tools/piano_roll.py +478 -0
  32. fl_studio_mcp/tools/plugin_dial.py +305 -0
  33. fl_studio_mcp/tools/plugin_v2.py +433 -0
  34. fl_studio_mcp/tools/plugins.py +407 -0
  35. fl_studio_mcp/tools/preset_v2.py +438 -0
  36. fl_studio_mcp/tools/probe_v2.py +78 -0
  37. fl_studio_mcp/tools/recipe_v2.py +1353 -0
  38. fl_studio_mcp/tools/screen_v2.py +159 -0
  39. fl_studio_mcp/tools/select_script.py +1064 -0
  40. fl_studio_mcp/tools/shortcut_v2.py +493 -0
  41. fl_studio_mcp/tools/slice_v2.py +81 -0
  42. fl_studio_mcp/tools/song_builder.py +378 -0
  43. fl_studio_mcp/tools/state.py +224 -0
  44. fl_studio_mcp/tools/transport.py +218 -0
  45. fl_studio_mcp/tools/transport_v2.py +247 -0
  46. fl_studio_mcp/tools/ui_screen_v2.py +148 -0
  47. fl_studio_mcp/tools/ui_script_v2.py +334 -0
  48. fl_studio_mcp/tools/variations_v2.py +139 -0
  49. fl_studio_mcp/tools/vocal_v2.py +673 -0
  50. fl_studio_mcp/utils/__init__.py +13 -0
  51. fl_studio_mcp/utils/add_channel.py +105 -0
  52. fl_studio_mcp/utils/add_effect.py +375 -0
  53. fl_studio_mcp/utils/channel_group.py +127 -0
  54. fl_studio_mcp/utils/chords.py +178 -0
  55. fl_studio_mcp/utils/clone_channel.py +141 -0
  56. fl_studio_mcp/utils/connection.py +92 -0
  57. fl_studio_mcp/utils/custom_menu.py +495 -0
  58. fl_studio_mcp/utils/drumkits.py +294 -0
  59. fl_studio_mcp/utils/edison_bridge.py +143 -0
  60. fl_studio_mcp/utils/edison_flow.py +291 -0
  61. fl_studio_mcp/utils/fl_panels.py +125 -0
  62. fl_studio_mcp/utils/fl_trigger.py +279 -0
  63. fl_studio_mcp/utils/humanize.py +63 -0
  64. fl_studio_mcp/utils/import_midi.py +245 -0
  65. fl_studio_mcp/utils/ledger.py +274 -0
  66. fl_studio_mcp/utils/midi_connection.py +350 -0
  67. fl_studio_mcp/utils/open_panel.py +109 -0
  68. fl_studio_mcp/utils/picker_index.py +222 -0
  69. fl_studio_mcp/utils/plugin_options.py +228 -0
  70. fl_studio_mcp/utils/plugin_preset_ui.py +511 -0
  71. fl_studio_mcp/utils/remove_effect.py +126 -0
  72. fl_studio_mcp/utils/roll_session.py +426 -0
  73. fl_studio_mcp/utils/sampler_load.py +130 -0
  74. fl_studio_mcp/utils/save_panel.py +109 -0
  75. fl_studio_mcp/utils/score_log.py +61 -0
  76. fl_studio_mcp/utils/song_loader.py +202 -0
  77. fl_studio_mcp/utils/ui_guard.py +250 -0
  78. mcp_flstudio-0.1.0.dist-info/METADATA +484 -0
  79. mcp_flstudio-0.1.0.dist-info/RECORD +82 -0
  80. mcp_flstudio-0.1.0.dist-info/WHEEL +4 -0
  81. mcp_flstudio-0.1.0.dist-info/entry_points.txt +3 -0
  82. mcp_flstudio-0.1.0.dist-info/licenses/LICENSE +23 -0
@@ -0,0 +1,3 @@
1
+ """FL Studio MCP Server - Control FL Studio via Model Context Protocol."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """Audio utilities (S4 slicing)."""
@@ -0,0 +1,169 @@
1
+ """fl_audio analyze: tempo / key / chords in pure numpy (no ML models, no extra envs).
2
+
3
+ Server-side, dependency-light (numpy + the system ffmpeg for decoding):
4
+
5
+ tempo two independent estimators over one onset envelope (log spectral flux):
6
+ ``autocorr`` (envelope autocorrelation) and ``tempogram`` (Fourier
7
+ tempogram, fundamental + 2nd harmonic). Both carry a log-normal prior at
8
+ 120 BPM; when they disagree beyond half/double, the caller reports a gap.
9
+ key Krumhansl-Kessler profiles correlated with the clip's mean chroma
10
+ (24 keys); ``strength`` = the Pearson r of the winner (< 0.5 = weak).
11
+ chords per-beat chroma vs major/minor triad templates (cosine); quiet windows
12
+ are 'N'. Labels are Harte-style ('C:maj', 'A:min') for ``chord_name``.
13
+
14
+ These are estimates for a producer to check by ear, not ground truth: gaps are
15
+ reported by the caller, never hidden.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import subprocess
21
+ from pathlib import Path
22
+
23
+ import numpy as np
24
+
25
+ SR = 22050
26
+ N_FFT = 2048
27
+ HOP = 512
28
+ FPS = SR / HOP
29
+ BPM_MIN, BPM_MAX = 60.0, 200.0
30
+ NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
31
+ # Krumhansl-Kessler key profiles (C major / C minor)
32
+ KK_MAJ = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
33
+ KK_MIN = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
34
+
35
+
36
+ def load(path: Path, seconds: float = 120.0) -> np.ndarray:
37
+ """Mono float audio at SR, at most ``seconds`` long (any format/bit depth, via ffmpeg)."""
38
+ raw = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", str(path), "-t", str(seconds),
39
+ "-ac", "1", "-ar", str(SR), "-f", "f32le", "-"],
40
+ capture_output=True, check=True, timeout=300).stdout
41
+ return np.frombuffer(raw, dtype=np.float32).astype(np.float64)
42
+
43
+
44
+ def spectrogram(x: np.ndarray) -> np.ndarray:
45
+ """|STFT|, shape (frames, N_FFT//2 + 1)."""
46
+ if x.size < N_FFT:
47
+ x = np.pad(x, (0, N_FFT - x.size))
48
+ n = 1 + (x.size - N_FFT) // HOP
49
+ idx = np.arange(N_FFT)[None, :] + HOP * np.arange(n)[:, None]
50
+ return np.abs(np.fft.rfft(x[idx] * np.hanning(N_FFT), axis=1))
51
+
52
+
53
+ def onset_envelope(mag: np.ndarray) -> np.ndarray:
54
+ """Half-wave-rectified log spectral flux, local mean removed, peak = 1."""
55
+ logm = np.log1p(100.0 * mag)
56
+ flux = np.maximum(0.0, np.diff(logm, axis=0)).sum(axis=1)
57
+ w = max(1, int(0.5 * FPS))
58
+ flux = np.maximum(0.0, flux - np.convolve(flux, np.ones(w) / w, mode="same"))
59
+ peak = flux.max() if flux.size else 0.0
60
+ return flux / peak if peak > 0 else flux
61
+
62
+
63
+ def _prior(bpm: np.ndarray) -> np.ndarray:
64
+ return np.exp(-0.5 * (np.log2(bpm / 120.0) / 1.0) ** 2)
65
+
66
+
67
+ def tempo_autocorr(env: np.ndarray) -> float | None:
68
+ if env.size < 4 * FPS:
69
+ return None
70
+ e = env - env.mean()
71
+ ac = np.fft.irfft(np.abs(np.fft.rfft(e, n=2 * e.size)) ** 2)[: e.size]
72
+ lags = np.arange(int(FPS * 60 / BPM_MAX), int(FPS * 60 / BPM_MIN) + 1)
73
+ lags = lags[lags < ac.size - 1]
74
+ if lags.size == 0 or ac[0] <= 0:
75
+ return None
76
+ score = ac[lags] / ac[0] * _prior(60 * FPS / lags)
77
+ i = int(np.argmax(score))
78
+ lag = float(lags[i])
79
+ if 0 < i < lags.size - 1: # parabolic refinement of the peak
80
+ a, b, c = ac[lags[i] - 1], ac[lags[i]], ac[lags[i] + 1]
81
+ den = a - 2 * b + c
82
+ if den != 0:
83
+ lag += 0.5 * (a - c) / den
84
+ return 60 * FPS / lag
85
+
86
+
87
+ def tempo_tempogram(env: np.ndarray) -> float | None:
88
+ if env.size < 4 * FPS:
89
+ return None
90
+ t = np.arange(env.size) / FPS
91
+ e = env - env.mean()
92
+ bpms = np.arange(BPM_MIN, BPM_MAX + 0.25, 0.25)
93
+
94
+ def mag(f: np.ndarray) -> np.ndarray:
95
+ return np.abs(np.exp(-2j * np.pi * f[:, None] * t[None, :]) @ e)
96
+
97
+ f = bpms / 60.0
98
+ score = (mag(f) + 0.5 * mag(2 * f)) * _prior(bpms)
99
+ return float(bpms[int(np.argmax(score))])
100
+
101
+
102
+ def tempo(x: np.ndarray) -> dict:
103
+ env = onset_envelope(spectrogram(x))
104
+ a, b = tempo_autocorr(env), tempo_tempogram(env)
105
+ agree = bool(a and b and min(abs(a - b), abs(a - 2 * b), abs(2 * a - b)) / max(a, b) < 0.04)
106
+ bpm = a or b
107
+ if agree: # the tempogram is finer; the autocorrelation picks the octave better
108
+ bpm = min((b / 2, b, b * 2), key=lambda v: abs(v - a))
109
+ r1 = lambda v: round(float(v), 1) if v else None # noqa: E731
110
+ return {"bpm": r1(bpm), "bpm_alt": [r1(bpm / 2), r1(bpm * 2)] if bpm else [],
111
+ "autocorr": r1(a), "tempogram": r1(b), "agree": agree}
112
+
113
+
114
+ def chroma(mag: np.ndarray) -> np.ndarray:
115
+ """Per-frame pitch-class energy (frames, 12), 55 Hz - 4.2 kHz."""
116
+ freqs = np.fft.rfftfreq(N_FFT, 1 / SR)
117
+ band = (freqs >= 55) & (freqs <= 4200)
118
+ pc = np.round(69 + 12 * np.log2(freqs[band] / 440.0)).astype(int) % 12
119
+ proj = np.zeros((band.sum(), 12))
120
+ proj[np.arange(pc.size), pc] = 1.0
121
+ m = mag[:, band]
122
+ peak = m.max() if m.size else 0.0
123
+ if peak > 0:
124
+ m = m / peak # level-independent compression
125
+ return np.log1p(100.0 * m ** 2) @ proj
126
+
127
+
128
+ def key(x: np.ndarray) -> dict:
129
+ c = chroma(spectrogram(x)).sum(axis=0)
130
+ if c.sum() <= 0:
131
+ return {"key": None, "scale": None, "strength": 0.0}
132
+ best = (-2.0, 0, "major")
133
+ for root in range(12):
134
+ for scale, prof in (("major", KK_MAJ), ("minor", KK_MIN)):
135
+ r = float(np.corrcoef(c, np.roll(prof, root))[0, 1])
136
+ if r > best[0]:
137
+ best = (r, root, scale)
138
+ return {"key": NOTES[best[1]], "scale": best[2], "strength": round(best[0], 3)}
139
+
140
+
141
+ def chords(x: np.ndarray, bpm: float | None = None) -> list[dict]:
142
+ """Chord per beat (or per 0.5 s without a tempo): [{chord, start, end, score}]."""
143
+ ch = chroma(spectrogram(x))
144
+ if ch.size == 0:
145
+ return []
146
+ win = max(1, int(round(FPS * (60.0 / bpm if bpm else 0.5))))
147
+ tmpl = []
148
+ for root in range(12):
149
+ for q, iv in (("maj", (0, 4, 7)), ("min", (0, 3, 7))):
150
+ v = np.zeros(12)
151
+ v[[(root + i) % 12 for i in iv]] = 1.0
152
+ tmpl.append((f"{NOTES[root]}:{q}", v / np.linalg.norm(v)))
153
+ energy = [ch[i:i + win].sum() for i in range(0, ch.shape[0], win)]
154
+ floor = 0.05 * float(np.median([e for e in energy if e > 0] or [0.0]))
155
+ out = []
156
+ for k, i in enumerate(range(0, ch.shape[0], win)):
157
+ v = ch[i:i + win].mean(axis=0)
158
+ start, end = i / FPS, min(i + win, ch.shape[0]) / FPS
159
+ if energy[k] <= floor or not v.any():
160
+ out.append({"chord": "N", "start": start, "end": end, "score": None})
161
+ continue
162
+ v = v - v.min() # drop the broadband floor before matching
163
+ n = np.linalg.norm(v)
164
+ if n == 0:
165
+ out.append({"chord": "N", "start": start, "end": end, "score": None})
166
+ continue
167
+ lab, s = max(((lab, float(t @ v / n)) for lab, t in tmpl), key=lambda p: p[1])
168
+ out.append({"chord": lab, "start": start, "end": end, "score": round(s, 3)})
169
+ return out
@@ -0,0 +1,283 @@
1
+ """S4.1: audio slicing — onset boundaries → slice files + manifest.
2
+
3
+ Server-side, no MIDI. Pipeline:
4
+
5
+ 1. read the audio (wav via stdlib; anything else decoded to mono float
6
+ with the system ffmpeg)
7
+ 2. onset detection (half-wave-rectified flux peak-picking — the same
8
+ class as librosa's superflux,
9
+ implemented in pure numpy so the MCP server stays dependency-light)
10
+ 3. boundaries: ``onset`` grid = detected onsets (min-gap merged);
11
+ ``16``/``32`` grid = uniform 16th/32nd-note steps at the given BPM
12
+ 4. each slice is extracted, **declicked** (3 ms raised-cosine edge
13
+ fades) and written as a 16-bit mono wav under
14
+ ``fl-studio-mcp/.generated/slices/<song>/`` (git-ignored, rule 9/10)
15
+ 5. manifest JSON: per-slice start/len (seconds + samples), energy →
16
+ velocity (40..127, relative to the loudest slice), a coarse
17
+ key guess (chroma mode over the whole clip), and the grid cell.
18
+
19
+ The manifest is the contract for S4.3 (``fl_load_slices``) and S4.2
20
+ (Edison region scripts): one entry per slice, in order.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import subprocess
27
+ import tempfile
28
+ import wave
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ import numpy as np
33
+
34
+ AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aiff", ".aif"}
35
+
36
+ #: slices shorter than this (seconds) are merged into their neighbour
37
+ MIN_SLICE_S = 0.06
38
+ HOP_S = 0.005 # 5 ms analysis hop (~882 frames/s at 44.1k)
39
+ MIN_GAP_S = 0.08 # hits closer than this keep the stronger
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Reading
44
+ # ---------------------------------------------------------------------------
45
+ def read_mono(path: Path, sr: int = 44100) -> np.ndarray:
46
+ """Return the file as mono float64 in [-1, 1] at ``sr``."""
47
+ path = Path(path)
48
+ if not path.is_file():
49
+ raise FileNotFoundError(path)
50
+ if path.suffix.lower() == ".wav":
51
+ with wave.open(str(path), "rb") as w:
52
+ n_ch, sw, fr = w.getnchannels(), w.getsampwidth(), w.getframerate()
53
+ raw = w.readframes(w.getnframes())
54
+ if sw == 2:
55
+ data = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
56
+ elif sw == 1:
57
+ data = (np.frombuffer(raw, dtype=np.uint8).astype(np.float64)
58
+ - 128.0) / 128.0
59
+ else:
60
+ raise ValueError(f"unsupported wav bit depth {sw * 8}")
61
+ if n_ch > 1:
62
+ data = data.reshape(-1, n_ch).mean(axis=1)
63
+ if fr != sr:
64
+ data = _resample(data, fr, sr)
65
+ return data
66
+ # non-wav: decode with ffmpeg to raw f32le mono
67
+ with tempfile.TemporaryDirectory() as td:
68
+ tmp = Path(td) / "decoded.raw"
69
+ subprocess.run(
70
+ ["ffmpeg", "-y", "-i", str(path), "-ac", "1", "-ar", str(sr),
71
+ "-f", "f32le", "-c:a", "pcm_f32le", str(tmp)],
72
+ check=True, capture_output=True)
73
+ data = np.fromfile(str(tmp), dtype=np.float32).astype(np.float64)
74
+ return data
75
+
76
+
77
+ def _resample(x: np.ndarray, sr0: int, sr1: int) -> np.ndarray:
78
+ if sr0 == sr1:
79
+ return x
80
+ n = int(len(x) * sr1 / sr0)
81
+ if n == 0:
82
+ return x
83
+ xp = np.linspace(0.0, 1.0, num=len(x), endpoint=False)
84
+ xq = np.linspace(0.0, 1.0, num=n, endpoint=False)
85
+ return np.interp(xq, xp, x)
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Onsets (HWR flux peak-picking)
90
+ # ---------------------------------------------------------------------------
91
+ def onset_times(audio: np.ndarray, sr: int,
92
+ min_gap_s: float = MIN_GAP_S,
93
+ threshold_pct: float = 50.0) -> list[float]:
94
+ """Onset times (seconds) via half-wave-rectified flux peak-picking.
95
+
96
+ The threshold is the higher of the flux mean and the ``threshold_pct``
97
+ percentile of the (0..1 normalised) flux. A pure mean+k·std threshold
98
+ is dominated by the loudest transients (kicks) and starves the quiet
99
+ ones (hats); the percentile floor adapts to the instrument mix. Raise
100
+ ``threshold_pct`` for dense, noisy material.
101
+ """
102
+ hop = max(1, int(sr * HOP_S))
103
+ pad = (-len(audio)) % hop
104
+ if pad:
105
+ audio = np.concatenate([audio, np.zeros(pad)])
106
+ n_frames = len(audio) // hop
107
+ if n_frames < 1:
108
+ return []
109
+ frames = np.asarray([audio[i * hop:(i + 1) * hop]
110
+ for i in range(n_frames)], dtype=np.float64)
111
+ # half-wave rectification
112
+ hwr = np.maximum(0.0, frames)
113
+ diff = np.diff(hwr, axis=0, prepend=hwr[:1])
114
+ flux = diff.sum(axis=1) / max(1, hwr.shape[1])
115
+ if len(flux) < 3 or flux.max() < 1e-6:
116
+ return []
117
+ flux = flux / flux.max()
118
+ mean = float(flux.mean())
119
+ thresh = max(mean, float(np.percentile(flux, threshold_pct)))
120
+ cand: list[tuple[float, float]] = []
121
+ for i in range(1, len(flux) - 1):
122
+ if flux[i] > thresh and flux[i] >= flux[i - 1] and flux[i] > flux[i + 1]:
123
+ cand.append((i * HOP_S, float(flux[i])))
124
+ # min-gap: keep the stronger of overlapping candidates
125
+ cand.sort(key=lambda c: c[0])
126
+ out: list[float] = []
127
+ last_t = -1e9
128
+ for t, e in cand:
129
+ if t - last_t >= min_gap_s:
130
+ out.append(round(t, 3))
131
+ last_t = t
132
+ return out
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Boundaries
137
+ # ---------------------------------------------------------------------------
138
+ def boundaries(duration_s: float, grid: str = "onset", bpm: float = 0.0,
139
+ onsets: list[float] | None = None,
140
+ min_slice_s: float = MIN_SLICE_S) -> tuple[list[float], dict]:
141
+ """Slice boundary times (start with 0.0, end with duration).
142
+
143
+ grid: ``onset`` (detected, min-gap merged), ``16`` (16th notes at bpm),
144
+ ``32`` (32nd notes at bpm).
145
+ """
146
+ if grid == "onset":
147
+ raw = [0.0] + sorted(t for t in (onsets or []) if 0 < t < duration_s)
148
+ if not raw or raw[-1] < duration_s:
149
+ raw.append(round(duration_s, 3))
150
+ merged = [raw[0]]
151
+ for t in raw[1:]:
152
+ if t - merged[-1] >= min_slice_s:
153
+ merged.append(t)
154
+ # else: drop the closer onset (merged into the previous slice)
155
+ if merged[-1] < duration_s:
156
+ merged[-1] = round(duration_s, 3)
157
+ meta = {"grid": "onset", "n_onsets": len(onsets or [])}
158
+ return merged, meta
159
+ if grid in ("16", "32"):
160
+ if not bpm:
161
+ raise ValueError(f"grid={grid!r} needs bpm")
162
+ bpm = float(bpm)
163
+ div = 4 if grid == "16" else 8
164
+ step = 60.0 / bpm / div
165
+ out = [0.0]
166
+ t = step
167
+ while t < duration_s - 1e-6:
168
+ out.append(round(t, 3))
169
+ t += step
170
+ out.append(round(duration_s, 3))
171
+ meta = {"grid": grid, "bpm": bpm, "step_s": round(step, 5)}
172
+ return out, meta
173
+ raise ValueError(f"unknown grid {grid!r} (onset|16|32)")
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Per-slice features
178
+ # ---------------------------------------------------------------------------
179
+ def slice_features(s: np.ndarray, sr: int, rmax: float) -> dict:
180
+ rms = float(np.sqrt(np.mean(np.square(s)))) if len(s) else 0.0
181
+ vel = 40 + int(round(87 * min(1.0, rms / rmax))) if rmax > 0 else 80
182
+ return {"rms": round(rms, 5), "velocity": int(min(127, max(40, vel)))}
183
+
184
+
185
+ def estimate_key(audio: np.ndarray, sr: int) -> str | None:
186
+ """Coarse key guess: 12-bin chroma mode over the whole clip."""
187
+ if len(audio) < sr:
188
+ return None
189
+ x = np.ascontiguousarray(audio[: min(len(audio), sr * 30)])
190
+ n = len(x)
191
+ fft = np.fft.rfft(x * np.hanning(n))
192
+ mag = np.abs(fft)
193
+ freqs = np.fft.rfftfreq(n, 1.0 / sr)
194
+ bins = np.zeros(12)
195
+ for f, m in zip(freqs, mag):
196
+ if f < 60 or f > 5000:
197
+ continue
198
+ midi = round(69.0 + 12.0 * np.log2(f / 440.0))
199
+ if midi < 0:
200
+ continue
201
+ bins[midi % 12] += m
202
+ if bins.max() <= 0:
203
+ return None
204
+ names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
205
+ return names[int(bins.argmax())]
206
+
207
+
208
+ def _declick(a: np.ndarray, sr: int, ms: float = 3.0) -> np.ndarray:
209
+ n = int(sr * ms / 1000)
210
+ a = a.copy()
211
+ if n * 2 < len(a) and n > 0:
212
+ fade = 0.5 * (1 - np.cos(np.linspace(0, np.pi, n)))
213
+ a[:n] *= fade
214
+ a[-n:] *= fade[::-1]
215
+ return a
216
+
217
+
218
+ def write_wav16(path: Path, a: np.ndarray, sr: int) -> None:
219
+ pcm = np.clip(a, -1.0, 1.0)
220
+ pcm = (pcm * 32767.0).astype("<i2")
221
+ with wave.open(str(path), "wb") as w:
222
+ w.setnchannels(1)
223
+ w.setsampwidth(2)
224
+ w.setframerate(sr)
225
+ w.writeframes(pcm.tobytes())
226
+
227
+
228
+ # ---------------------------------------------------------------------------
229
+ # Driver
230
+ # ---------------------------------------------------------------------------
231
+ def slice_audio(path: Path, out_root: Path, song: str,
232
+ grid: str = "onset", bpm: float = 0.0,
233
+ sr: int = 44100) -> dict[str, Any]:
234
+ """Run the whole pipeline; returns the manifest dict and writes
235
+ slice wavs + manifest.json under ``out_root/song/``."""
236
+ audio = read_mono(path, sr)
237
+ duration = len(audio) / sr
238
+ onsets = onset_times(audio, sr)
239
+ bounds, meta = boundaries(duration, grid=grid, bpm=bpm, onsets=onsets)
240
+
241
+ out_dir = out_root / song
242
+ out_dir.mkdir(parents=True, exist_ok=True)
243
+
244
+ slices = []
245
+ rmax = 0.0
246
+ segs = []
247
+ for i in range(len(bounds) - 1):
248
+ s0, s1 = int(bounds[i] * sr), int(min(len(audio), bounds[i + 1] * sr))
249
+ seg = audio[s0:s1]
250
+ r = float(np.sqrt(np.mean(np.square(seg)))) if len(seg) else 0.0
251
+ rmax = max(rmax, r)
252
+ segs.append((seg, s0, s1))
253
+ for i, (seg, s0, s1) in enumerate(segs):
254
+ clean = _declick(seg, sr)
255
+ fname = f"slice_{i:03d}.wav"
256
+ write_wav16(out_dir / fname, clean, sr)
257
+ feat = slice_features(seg, sr, rmax)
258
+ slices.append({
259
+ "index": i,
260
+ "file": fname,
261
+ "start_s": round(s0 / sr, 3),
262
+ "len_s": round(max(0, s1 - s0) / sr, 3),
263
+ "start_sample": s0,
264
+ "len_samples": max(0, s1 - s0),
265
+ "velocity": feat["velocity"],
266
+ "rms": feat["rms"],
267
+ })
268
+ manifest: dict[str, Any] = {
269
+ "song": song,
270
+ "source": str(path),
271
+ "sr": sr,
272
+ "duration_s": round(duration, 3),
273
+ "grid": meta.get("grid", grid),
274
+ "bpm": meta.get("bpm", bpm or 0),
275
+ "key_guess": estimate_key(audio, sr),
276
+ "n_slices": len(slices),
277
+ "slices": slices,
278
+ "note": ("Generated by fl_slice_audio; private learning material — "
279
+ "not for shared datasets (rule 10)."),
280
+ }
281
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
282
+ manifest["manifest_path"] = str(out_dir / "manifest.json")
283
+ return manifest