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.
- fl_studio_mcp/__init__.py +3 -0
- fl_studio_mcp/audio/__init__.py +1 -0
- fl_studio_mcp/audio/analysis.py +169 -0
- fl_studio_mcp/audio/slicer.py +283 -0
- fl_studio_mcp/dispatcher.py +386 -0
- fl_studio_mcp/pilot_a6.py +302 -0
- fl_studio_mcp/pilot_a6_run.py +99 -0
- fl_studio_mcp/preset_paths.py +87 -0
- fl_studio_mcp/safety.py +437 -0
- fl_studio_mcp/server.py +285 -0
- fl_studio_mcp/tools/__init__.py +29 -0
- fl_studio_mcp/tools/audio_v2.py +287 -0
- fl_studio_mcp/tools/batch.py +234 -0
- fl_studio_mcp/tools/channel_v2.py +384 -0
- fl_studio_mcp/tools/channels.py +454 -0
- fl_studio_mcp/tools/delete.py +194 -0
- fl_studio_mcp/tools/edison_v2.py +161 -0
- fl_studio_mcp/tools/exec_python.py +153 -0
- fl_studio_mcp/tools/file_v2.py +152 -0
- fl_studio_mcp/tools/genre_v2.py +306 -0
- fl_studio_mcp/tools/help_v2.py +369 -0
- fl_studio_mcp/tools/launchpad.py +418 -0
- fl_studio_mcp/tools/lifecycle_v2.py +757 -0
- fl_studio_mcp/tools/markers.py +91 -0
- fl_studio_mcp/tools/mixer.py +294 -0
- fl_studio_mcp/tools/mixer_tracks.py +199 -0
- fl_studio_mcp/tools/mixer_v2.py +723 -0
- fl_studio_mcp/tools/notes_v2.py +507 -0
- fl_studio_mcp/tools/pattern_v2.py +417 -0
- fl_studio_mcp/tools/patterns.py +83 -0
- fl_studio_mcp/tools/piano_roll.py +478 -0
- fl_studio_mcp/tools/plugin_dial.py +305 -0
- fl_studio_mcp/tools/plugin_v2.py +433 -0
- fl_studio_mcp/tools/plugins.py +407 -0
- fl_studio_mcp/tools/preset_v2.py +438 -0
- fl_studio_mcp/tools/probe_v2.py +78 -0
- fl_studio_mcp/tools/recipe_v2.py +1353 -0
- fl_studio_mcp/tools/screen_v2.py +159 -0
- fl_studio_mcp/tools/select_script.py +1064 -0
- fl_studio_mcp/tools/shortcut_v2.py +493 -0
- fl_studio_mcp/tools/slice_v2.py +81 -0
- fl_studio_mcp/tools/song_builder.py +378 -0
- fl_studio_mcp/tools/state.py +224 -0
- fl_studio_mcp/tools/transport.py +218 -0
- fl_studio_mcp/tools/transport_v2.py +247 -0
- fl_studio_mcp/tools/ui_screen_v2.py +148 -0
- fl_studio_mcp/tools/ui_script_v2.py +334 -0
- fl_studio_mcp/tools/variations_v2.py +139 -0
- fl_studio_mcp/tools/vocal_v2.py +673 -0
- fl_studio_mcp/utils/__init__.py +13 -0
- fl_studio_mcp/utils/add_channel.py +105 -0
- fl_studio_mcp/utils/add_effect.py +375 -0
- fl_studio_mcp/utils/channel_group.py +127 -0
- fl_studio_mcp/utils/chords.py +178 -0
- fl_studio_mcp/utils/clone_channel.py +141 -0
- fl_studio_mcp/utils/connection.py +92 -0
- fl_studio_mcp/utils/custom_menu.py +495 -0
- fl_studio_mcp/utils/drumkits.py +294 -0
- fl_studio_mcp/utils/edison_bridge.py +143 -0
- fl_studio_mcp/utils/edison_flow.py +291 -0
- fl_studio_mcp/utils/fl_panels.py +125 -0
- fl_studio_mcp/utils/fl_trigger.py +279 -0
- fl_studio_mcp/utils/humanize.py +63 -0
- fl_studio_mcp/utils/import_midi.py +245 -0
- fl_studio_mcp/utils/ledger.py +274 -0
- fl_studio_mcp/utils/midi_connection.py +350 -0
- fl_studio_mcp/utils/open_panel.py +109 -0
- fl_studio_mcp/utils/picker_index.py +222 -0
- fl_studio_mcp/utils/plugin_options.py +228 -0
- fl_studio_mcp/utils/plugin_preset_ui.py +511 -0
- fl_studio_mcp/utils/remove_effect.py +126 -0
- fl_studio_mcp/utils/roll_session.py +426 -0
- fl_studio_mcp/utils/sampler_load.py +130 -0
- fl_studio_mcp/utils/save_panel.py +109 -0
- fl_studio_mcp/utils/score_log.py +61 -0
- fl_studio_mcp/utils/song_loader.py +202 -0
- fl_studio_mcp/utils/ui_guard.py +250 -0
- mcp_flstudio-0.1.0.dist-info/METADATA +484 -0
- mcp_flstudio-0.1.0.dist-info/RECORD +82 -0
- mcp_flstudio-0.1.0.dist-info/WHEEL +4 -0
- mcp_flstudio-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_flstudio-0.1.0.dist-info/licenses/LICENSE +23 -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
|