Files
bass-foundry/scripts/analyze-references.py
T
nvrl 3281535a04
Check and build / core (push) Successful in 51s
Check and build / plugin (ubuntu-latest) (push) Failing after 32s
Check and build / plugin (windows-latest) (push) Canceled after 0s
feat: add Motif Storyboard composition plugin
2026-11-05 22:39:36 +01:00

81 lines
3.9 KiB
Python

"""Measure rendered-mix level, attacks, and texture changes in local FLACs.
Requires ffmpeg, numpy, matplotlib. This does not transcribe melodies or chords.
Positive spectral flux includes percussion; RMS is influenced by mastering.
"""
import os
os.environ['MPLCONFIGDIR'] = '/tmp/motif-matplotlib'
import subprocess, json, argparse
from pathlib import Path
import numpy as np
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
parser = argparse.ArgumentParser(description='Measure relative level and attack/texture changes in local FLAC references. Requires ffmpeg, numpy, and matplotlib. Does not transcribe notes or infer emotion.')
parser.add_argument('folder', nargs='?', default='.')
parser.add_argument('--output', default='docs/reference-analysis')
args = parser.parse_args()
root = Path(args.folder)
out = Path(args.output)
out.mkdir(parents=True, exist_ok=True)
files = sorted(root.glob('*.flac'))
if not files:
parser.error('No FLAC files in the selected folder.')
fig, axes = plt.subplots(len(files), 1, squeeze=False, figsize=(15, 2.0 * len(files)), constrained_layout=True)
summary = []
axes = axes[:, 0]
for ax, path in zip(axes, files):
raw = subprocess.check_output(['ffmpeg', '-v', 'error', '-i', str(path), '-f', 'f32le', '-ac', '1', '-ar', '11025', 'pipe:1'])
y = np.frombuffer(raw, dtype='<f4')
sr = 11025
hop = 256
size = 2048
frames = np.lib.stride_tricks.sliding_window_view(y, size)[::hop]
window = np.hanning(size)
spectra = []
rms = []
for start in range(0, len(frames), 256):
f = frames[start:start + 256]
spectra.append(np.abs(np.fft.rfft(f * window, axis=1)).astype(np.float32))
rms.extend(np.sqrt(np.mean(f * f, axis=1)))
mag = np.concatenate(spectra)
freq = np.fft.rfftfreq(size, 1 / sr)
times = np.arange(len(mag)) * hop / sr
log = np.log1p(mag)
flux = np.maximum(np.diff(log, axis=0, prepend=log[:1]), 0).sum(axis=1)
one = np.arange(0, len(y) / sr, 1.0)
def seconds(values):
return np.array([np.mean(values[(times >= t) & (times < t + 1)]) if np.any((times >= t) & (times < t + 1)) else 0 for t in one])
energy = seconds(np.asarray(rms))
attack = seconds(flux)
bands = np.stack([seconds(mag[:, (freq >= lo) & (freq < hi)].sum(axis=1)) for lo, hi in [(40, 180), (180, 1000), (1000, 5000)]], axis=1)
features = np.log1p(bands)
features = (features - features.mean(0)) / (features.std(0) + 1e-08)
novelty = np.zeros(len(one))
for t in range(8, len(one) - 8):
novelty[t] = np.linalg.norm(features[t - 8:t].mean(0) - features[t:t + 8].mean(0))
peaks = []
for idx in np.argsort(novelty)[::-1]:
if idx < 16 or idx > len(one) - 16:
continue
if all((abs(int(idx) - p) >= 16 for p in peaks)):
peaks.append(int(idx))
if len(peaks) == 6:
break
peaks.sort()
ax.plot(one, energy / (np.percentile(energy, 95) + 1e-08), label='Relative RMS', color='#8b77cf', linewidth=1)
ax.plot(one, attack / (np.percentile(attack, 95) + 1e-08), label='Spectral attacks', color='#3f8d9e', alpha=0.65, linewidth=0.8)
for p in peaks:
ax.axvline(p, color='#b7823c', alpha=0.45, linewidth=0.7)
ax.text(p, 1.65, f'{p // 60}:{p % 60:02}', fontsize=7, rotation=90)
ax.set(title=path.stem, xlim=(0, len(y) / sr), ylim=(0, 2), ylabel='Relative level')
ax.grid(alpha=0.15)
item = {'track': path.stem, 'duration_seconds': round(len(y) / sr, 2), 'candidate_texture_changes_seconds': peaks, 'rms_20th_to_80th_ratio': round(float(np.percentile(energy, 80) / (np.percentile(energy, 20) + 1e-08)), 2), 'spectral_attack_20th_to_80th_ratio': round(float(np.percentile(attack, 80) / (np.percentile(attack, 20) + 1e-08)), 2)}
summary.append(item)
print(json.dumps(item), flush=True)
axes[0].legend(loc='upper right')
axes[-1].set_xlabel('Seconds')
fig.savefig(out / 'reference-timelines.png', dpi=130)
(out / 'measurements.json').write_text(json.dumps(summary, indent=2))