Alle serverseitigen Scripts zur Transparenz einsehbar. Keine Daten werden gespeichert oder weitergeleitet. — ← Zurück zur Toolbox
#!/usr/bin/env python3
"""BPM detection - multi-hypothesis autocorrelation with harmonic ranking"""
import sys, json, math
import numpy as np
def detect_bpm(filepath):
try:
import soundfile as sf
from scipy.ndimage import uniform_filter1d
y, sr = sf.read(filepath, always_2d=True)
y = y.mean(axis=1).astype(np.float32)
# Skip 3s intro, use max 90s
start = min(int(sr * 3), len(y) // 10)
end = min(len(y), start + int(sr * 90))
y = y[start:end]
# Work at native sample rate but with coarse hop for speed
hop = max(int(sr * 0.005), 1)
frame = hop * 8
# Onset strength
rms = np.array([
np.sqrt(np.mean(y[i:i+frame]**2))
for i in range(0, len(y) - frame, hop)
], dtype=np.float32)
onset = np.maximum(0, np.diff(rms, prepend=rms[0]))
onset = uniform_filter1d(onset, size=5)
mx = onset.max()
if mx > 0: onset /= mx
fps = sr / hop
# Test range: 60-200 BPM at 0.5 BPM resolution
bpm_candidates = np.arange(60.0, 200.5, 0.5)
scores = np.zeros(len(bpm_candidates))
for i, bpm in enumerate(bpm_candidates):
lag = fps * 60.0 / bpm
lag_int = int(round(lag))
if lag_int <= 0 or lag_int >= len(onset): continue
n = min(len(onset) - lag_int, 6000)
if n < 100: continue
scores[i] = np.dot(onset[:n], onset[lag_int:lag_int+n]) / n
# Smooth scores
scores_smooth = uniform_filter1d(scores, size=3)
# Find top 5 peaks
from scipy.signal import find_peaks
peaks, props = find_peaks(scores_smooth, height=scores_smooth.mean(), distance=4)
if len(peaks) == 0:
best_i = int(np.argmax(scores_smooth))
peaks = np.array([best_i])
# Sort by score
peak_scores = scores_smooth[peaks]
sorted_peaks = peaks[np.argsort(peak_scores)[::-1]][:5]
# Score each candidate considering harmonics
# Prefer candidates where bpm/2, bpm, bpm*2 all have good scores
def harmonic_score(bpm_val):
s = 0.0
for mult in [0.5, 1.0, 2.0, 4.0]:
h = bpm_val * mult
if 60 <= h <= 200:
idx = int(round((h - 60) / 0.5))
if 0 <= idx < len(scores_smooth):
weight = 1.0 if mult == 1.0 else 0.4
s += scores_smooth[idx] * weight
# Penalize BPM > 150 to prefer musical tempo over subdivision
if bpm_val > 150:
s *= (150.0 / bpm_val) ** 0.8
return s
best_bpm = bpm_candidates[sorted_peaks[0]]
best_score = 0
for pi in sorted_peaks:
bpm_val = bpm_candidates[pi]
hs = harmonic_score(bpm_val)
if hs > best_score:
best_score = hs
best_bpm = bpm_val
# If best_bpm > 130, check if half-BPM region has a clear peak
# This handles tracks where 16th-note grid scores higher than beat grid
if best_bpm > 130:
half_center = best_bpm / 2.0
# Search in +-5 BPM window around half_center
half_candidates = [c for c in bpm_candidates if abs(c - half_center) <= 5]
if half_candidates:
half_scores = []
for hc in half_candidates:
lag = int(round(fps * 60.0 / hc))
if lag <= 0 or lag >= len(onset):
half_scores.append(0)
continue
n = min(len(onset)-lag, 6000)
half_scores.append(np.dot(onset[:n], onset[lag:lag+n]) / n if n > 0 else 0)
best_half_idx = int(np.argmax(half_scores))
best_half_bpm = half_candidates[best_half_idx]
best_half_score = half_scores[best_half_idx]
# Use half if its score is > 45% of the original
orig_lag = int(round(fps * 60.0 / best_bpm))
n = min(len(onset)-orig_lag, 6000)
orig_score = np.dot(onset[:n], onset[orig_lag:orig_lag+n]) / n if n > 0 else 0
if best_half_score > orig_score * 0.45:
best_bpm = best_half_bpm
# Final round to nearest integer
best_bpm = float(math.floor(best_bpm + 0.5)) # Round half up like DAWs
# Confidence
peak_val = scores_smooth[int(round((best_bpm - 60) / 0.5))] if 60 <= best_bpm <= 200 else 0
mean_val = np.mean(scores_smooth)
# Confidence based on harmonic ratio
max_harm_score = max(
scores_smooth[int(round((best_bpm*m - 60)/0.5))]
for m in [1.0, 2.0, 0.5]
if 60 <= best_bpm*m <= 200 and 0 <= int(round((best_bpm*m-60)/0.5)) < len(scores_smooth)
)
ratio = max_harm_score / (float(np.mean(scores_smooth)) + 1e-9)
confidence = 92 if ratio >= 2.0 else 82 if ratio >= 1.5 else 72 if ratio >= 1.2 else 62
beat_count = int(best_bpm * (len(y) / sr) / 60)
return {
'bpm': best_bpm,
'bpmRounded': int(best_bpm),
'halfTime': round(best_bpm / 2),
'doubleTime': int(best_bpm * 2),
'confidence': confidence,
'beatCount': beat_count,
'method': 'multi-hypothesis'
}
except Exception as e:
import traceback
return {'error': str(e) + ' | ' + traceback.format_exc().split('\n')[-2]}
if __name__ == '__main__':
if len(sys.argv) < 2:
print(json.dumps({'error': 'No file'})); sys.exit(1)
print(json.dumps(detect_bpm(sys.argv[1])))
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload fehlgeschlagen']); exit;
}
$file = $_FILES['audio'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$allowed = ['wav','mp3','ogg','flac','aiff','aif','m4a'];
if (!in_array($ext, $allowed)) {
echo json_encode(['error' => 'Format nicht unterstützt']); exit;
}
if ($file['size'] > 100 * 1024 * 1024) {
echo json_encode(['error' => 'Datei zu groß (max 100MB)']); exit;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'bpm_') . '.' . $ext;
move_uploaded_file($file['tmp_name'], $tmpFile);
putenv("NUMBA_DISABLE_JIT=1");
$python = '/opt/demucs-env/bin/python3';
$script = __DIR__ . '/bpm_detect.py';
$cmd = "NUMBA_DISABLE_JIT=1 " . escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($tmpFile) . ' 2>&1';
$output = shell_exec($cmd);
unlink($tmpFile);
if (!$output) {
echo json_encode(['error' => 'BPM-Analyse fehlgeschlagen']); exit;
}
$result = json_decode($output, true);
if (!$result) {
echo json_encode(['error' => 'Ungültige Ausgabe: ' . substr($output, 0, 200)]); exit;
}
echo json_encode($result);
?>
#!/usr/bin/env python3
"""Chord detection using librosa CQT chroma + Krumhansl-Kessler key profiles"""
import sys, json, os
os.environ["NUMBA_CACHE_DIR"] = "/var/cache/numba_chord"
os.makedirs("/var/cache/numba_chord", exist_ok=True)
import numpy as np
CHORD_TEMPLATES = {
'C': [1,0,0,0,1,0,0,1,0,0,0,0],
'Cm': [1,0,0,1,0,0,0,1,0,0,0,0],
'C7': [1,0,0,0,1,0,0,1,0,0,1,0],
'Cmaj7':[1,0,0,0,1,0,0,1,0,0,0,1],
'C#': [0,1,0,0,0,1,0,0,1,0,0,0],
'C#m': [0,1,0,0,1,0,0,0,1,0,0,0],
'C#7': [0,1,0,0,0,1,0,0,1,0,1,0],
'D': [0,0,1,0,0,0,1,0,0,1,0,0],
'Dm': [0,0,1,0,0,1,0,0,0,1,0,0],
'D7': [0,0,1,0,0,0,1,0,0,1,0,1],
'Dmaj7':[0,1,1,0,0,0,1,0,0,1,0,0],
'D#': [0,0,0,1,0,0,0,1,0,0,1,0],
'D#m': [0,0,0,1,0,0,1,0,0,0,1,0],
'E': [0,0,0,0,1,0,0,0,1,0,0,1],
'Em': [0,0,0,0,1,0,0,1,0,0,0,1],
'E7': [0,0,0,0,1,0,0,0,1,0,1,0],
'F': [1,0,0,0,0,1,0,0,0,1,0,0],
'Fm': [1,0,0,0,0,1,0,0,1,0,0,0],
'F7': [1,0,0,0,0,1,0,0,0,1,0,1],
'F#': [0,1,0,0,0,0,1,0,0,0,1,0],
'F#m': [0,1,0,0,0,0,1,0,0,1,0,0],
'F#7': [0,1,0,0,0,0,1,0,0,0,1,1],
'G': [0,0,1,0,0,0,0,1,0,0,0,1],
'Gm': [0,0,1,0,0,0,0,1,0,0,1,0],
'G7': [0,0,1,0,0,0,0,1,0,0,1,1],
'Gmaj7':[0,0,1,0,0,0,0,1,0,0,0,1],
'G#': [1,0,0,1,0,0,0,0,1,0,0,0],
'G#m': [1,0,0,1,0,0,0,0,1,0,0,0],
'A': [1,0,0,0,1,0,0,0,0,1,0,0],
'Am': [1,0,0,0,1,0,0,0,0,1,0,1],
'A7': [1,0,0,0,1,0,0,1,0,0,1,0],
'Amaj7':[1,0,0,0,1,0,1,0,0,1,0,0],
'A#': [0,1,0,0,0,1,0,0,0,0,1,0],
'A#m': [0,1,0,0,0,1,0,0,0,0,1,0],
'A#7': [0,1,0,0,0,1,0,0,1,0,0,1],
'B': [0,0,0,1,0,0,1,0,0,0,0,1],
'Bm': [0,0,1,0,0,0,1,0,0,0,0,1],
'B7': [0,1,0,0,1,0,0,1,0,0,0,1],
}
def chroma_to_chord(chroma):
chroma = np.array(chroma, dtype=float)
norm = np.linalg.norm(chroma)
if norm < 1e-6:
return 'N', 0.0
chroma = chroma / norm
best_chord, best_score = 'N', -1.0
for name, template in CHORD_TEMPLATES.items():
t = np.array(template, dtype=float)
t = t / np.linalg.norm(t)
score = float(np.dot(chroma, t))
if score > best_score:
best_score = score
best_chord = name
return best_chord, round(best_score, 3)
def detect_chords(filepath):
try:
import librosa
from scipy.ndimage import median_filter
from collections import Counter
# Load full track, no duration limit
y, sr = librosa.load(filepath, sr=22050, mono=True)
audio_duration = round(float(len(y)) / sr, 1)
hop_length = 2048 # ~93ms per frame
sec_per_frame = hop_length / sr
chroma = librosa.feature.chroma_cqt(
y=y, sr=sr,
hop_length=hop_length,
bins_per_octave=36,
norm=2
)
chroma_smooth = median_filter(chroma, size=(1, 5))
# Chord per frame with timestamp
frame_chords = []
for t in range(chroma_smooth.shape[1]):
chord, score = chroma_to_chord(chroma_smooth[:, t])
frame_chords.append((chord, score, t * sec_per_frame))
# Merge consecutive — keep start time of each segment
merged = []
if frame_chords:
cur_chord, cur_score, cur_start = frame_chords[0]
count = 1
for chord, score, ts in frame_chords[1:]:
if chord == cur_chord:
count += 1
cur_score = max(cur_score, score)
else:
dur = count * sec_per_frame
if cur_chord != 'N' and dur >= 0.8 and cur_score >= 0.72:
merged.append({
'chord': cur_chord,
'duration': round(dur, 1),
'start': round(cur_start, 1),
'confidence': round(cur_score * 100)
})
cur_chord, cur_score, cur_start, count = chord, score, ts, 1
dur = count * sec_per_frame
if cur_chord != 'N' and dur >= 0.8 and cur_score >= 0.72:
merged.append({
'chord': cur_chord,
'duration': round(dur, 1),
'start': round(cur_start, 1),
'confidence': round(cur_score * 100)
})
# Key detection
key_names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
chroma_mean = chroma.mean(axis=1)
major_profile = 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])
minor_profile = 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])
best_key, best_score, best_mode = 'C', -99.0, 'major'
for shift in range(12):
maj = float(np.corrcoef(np.roll(major_profile, shift), chroma_mean)[0,1])
min_ = float(np.corrcoef(np.roll(minor_profile, shift), chroma_mean)[0,1])
if maj > best_score:
best_score, best_key, best_mode = maj, key_names[shift], 'major'
if min_ > best_score:
best_score, best_key, best_mode = min_, key_names[shift], 'minor'
key_display = best_key + ('m' if best_mode == 'minor' else '')
chord_counts = Counter(c['chord'] for c in merged)
unique_chords = [c for c, _ in chord_counts.most_common(8)]
return {
'key': key_display,
'chords': merged[:80],
'uniqueChords': unique_chords,
'totalChanges': len(merged),
'audioDuration': audio_duration
}
except Exception as e:
import traceback
return {'error': str(e), 'trace': traceback.format_exc()[-600:]}
if __name__ == '__main__':
if len(sys.argv) < 2:
print(json.dumps({'error': 'No file'})); sys.exit(1)
print(json.dumps(detect_chords(sys.argv[1])))
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
set_time_limit(120);
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload fehlgeschlagen']); exit;
}
$file = $_FILES['audio'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['wav','mp3','ogg','flac','aiff','aif','m4a'])) {
echo json_encode(['error' => 'Format nicht unterstützt']); exit;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'chord_') . '.' . $ext;
move_uploaded_file($file['tmp_name'], $tmpFile);
$python = '/opt/chord-env/bin/python3';
$script = __DIR__ . '/chord_detect.py';
$cmd = 'NUMBA_CACHE_DIR=/var/cache/numba_chord ' . escapeshellarg($python) . ' ' . escapeshellarg($script) . ' ' . escapeshellarg($tmpFile) . ' 2>/dev/null';
$output = shell_exec($cmd);
unlink($tmpFile);
if (!$output) { echo json_encode(['error' => 'Analyse fehlgeschlagen']); exit; }
$result = json_decode($output, true);
if (!$result) { echo json_encode(['error' => 'Ungültige Ausgabe', 'raw' => substr($output,0,300)]); exit; }
echo json_encode($result);
?>
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
set_time_limit(30);
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['error' => 'Upload fehlgeschlagen']); exit;
}
$file = $_FILES['audio'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if (!in_array($ext, ['wav','mp3','ogg','flac','aiff','aif','m4a'])) {
echo json_encode(['error' => 'Format nicht unterstützt']); exit;
}
if ($file['size'] > 100 * 1024 * 1024) {
echo json_encode(['error' => 'Datei zu groß (max 100MB)']); exit;
}
$stems = $_POST['stems'] ?? '4stems';
$jobId = uniqid('stem_');
$jobDir = '/tmp/stemjobs/' . $jobId;
mkdir($jobDir, 0777, true);
// Save uploaded file
$inputFile = $jobDir . '/input.' . $ext;
move_uploaded_file($file['tmp_name'], $inputFile);
// Write job config
file_put_contents($jobDir . '/config.json', json_encode([
'stems' => $stems,
'ext' => $ext,
'status' => 'pending'
]));
// Start demucs in background
$python = '/opt/demucs-env/bin/python3';
$outDir = $jobDir . '/output';
mkdir($outDir, 0777, true);
if ($stems === '2stems') {
$cmd = $mem_limit . "XDG_CACHE_HOME=/tmp/demucs_cache HOME=/tmp $python -m demucs -d cpu --two-stems=vocals --mp3 --out " . escapeshellarg($outDir) . " " . escapeshellarg($inputFile);
} elseif ($stems === '6stems') {
$cmd = $mem_limit . "XDG_CACHE_HOME=/tmp/demucs_cache HOME=/tmp $python -m demucs -d cpu -n htdemucs_6s --mp3 --out " . escapeshellarg($outDir) . " " . escapeshellarg($inputFile);
} else {
$cmd = $mem_limit . "XDG_CACHE_HOME=/tmp/demucs_cache HOME=/tmp $python -m demucs -d cpu --mp3 --out " . escapeshellarg($outDir) . " " . escapeshellarg($inputFile);
}
// Run in background, write status file when done
$wrapCmd = "($cmd && echo 'done' > " . escapeshellarg($jobDir . '/done') . ") > " . escapeshellarg($jobDir . '/log.txt') . " 2>&1 &";
shell_exec($wrapCmd);
echo json_encode(['jobId' => $jobId, 'status' => 'started']);
?>
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$jobId = $_GET['jobId'] ?? '';
if (!preg_match('/^stem_[a-f0-9]+$/', $jobId)) {
echo json_encode(['error' => 'Invalid job ID']); exit;
}
$jobDir = '/tmp/stemjobs/' . $jobId;
if (!is_dir($jobDir)) {
echo json_encode(['error' => 'Job not found']); exit;
}
if (!file_exists($jobDir . '/done')) {
// Still running - check log for progress
$log = file_get_contents($jobDir . '/log.txt') ?: '';
echo json_encode(['status' => 'running', 'log' => substr($log, -200)]);
exit;
}
// Job done - find stem files
$stemFiles = glob($jobDir . '/output/*/*/*.mp3');
if (empty($stemFiles)) $stemFiles = glob($jobDir . '/output/*/*/*/*.mp3');
if (empty($stemFiles)) {
$log = file_get_contents($jobDir . '/log.txt') ?: '';
echo json_encode(['status' => 'error', 'error' => 'Keine Stems gefunden', 'log' => substr($log, -500)]);
exit;
}
$stems = [];
foreach ($stemFiles as $f) {
$name = basename($f, '.mp3');
// Copy to web-accessible location
$webDir = __DIR__ . '/stems/' . $jobId;
if (!is_dir($webDir)) mkdir($webDir, 0755, true);
$webFile = $webDir . '/' . $name . '.mp3';
if (!file_exists($webFile)) copy($f, $webFile);
$stems[] = [
'name' => ucfirst(str_replace('_', ' ', $name)),
'file' => $name,
'url' => '/toolbox/stems/' . $jobId . '/' . $name . '.mp3',
'size' => filesize($webFile)
];
}
echo json_encode(['status' => 'done', 'stems' => $stems]);
?>
<?php
header('Access-Control-Allow-Origin: *');
// Validate inputs
$mode = $_POST['mode'] ?? '';
$allowedModes = ['432', '444'];
if (!in_array($mode, $allowedModes)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid mode']);
exit;
}
// Get uploaded file
if (!isset($_FILES['audio']) || $_FILES['audio']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'No audio file uploaded']);
exit;
}
$uploadedFile = $_FILES['audio']['tmp_name'];
$originalName = pathinfo($_FILES['audio']['name'], PATHINFO_FILENAME);
$ext = strtolower(pathinfo($_FILES['audio']['name'], PATHINFO_EXTENSION));
$allowedExts = ['wav', 'mp3', 'flac', 'aiff', 'aif', 'ogg', 'm4a'];
if (!in_array($ext, $allowedExts)) {
http_response_code(400);
echo json_encode(['error' => 'Unsupported file format']);
exit;
}
// Max 100MB
if ($_FILES['audio']['size'] > 100 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['error' => 'File too large (max 100MB)']);
exit;
}
// Calculate pitch shift ratio
// A440 -> A432: ratio = 432/440 = 0.981818...
// A440 -> A444: ratio = 444/440 = 1.009090...
// Rubberband uses semitones: semitones = 12 * log2(target/source)
if ($mode === '432') {
$semitones = 12 * log(432/440) / log(2); // -0.3170 semitones
$targetHz = 432;
} else {
$semitones = 12 * log(444/440) / log(2); // +0.1565 semitones
$targetHz = 444;
}
// Create unique temp directory
$tmpDir = sys_get_temp_dir() . '/pitch_' . uniqid();
mkdir($tmpDir, 0700, true);
$inputWav = $tmpDir . '/input.wav';
$outputWav = $tmpDir . '/output.wav';
$outputFile = $tmpDir . '/output.' . $ext;
// Convert to WAV first (rubberband works best with WAV)
$ffmpegIn = escapeshellarg($uploadedFile);
$ffmpegOut = escapeshellarg($inputWav);
exec("ffmpeg -y -i $ffmpegIn -ar 44100 -ac 2 $ffmpegOut 2>&1", $ffmpegOutput, $ffmpegCode);
if ($ffmpegCode !== 0 || !file_exists($inputWav)) {
// Cleanup
array_map('unlink', glob("$tmpDir/*"));
rmdir($tmpDir);
http_response_code(500);
echo json_encode(['error' => 'Audio conversion failed', 'details' => implode("\n", $ffmpegOutput)]);
exit;
}
// Run rubberband
// --pitch-hq = high quality pitch shifting
// --formant = preserve vocal formants (important for voices)
$semStr = escapeshellarg(number_format($semitones, 6, '.', ''));
$rbInput = escapeshellarg($inputWav);
$rbOutput = escapeshellarg($outputWav);
$cmd = "rubberband --pitch $semStr --pitch-hq --formant $rbInput $rbOutput 2>&1";
exec($cmd, $rbOutput_log, $rbCode);
if ($rbCode !== 0 || !file_exists($outputWav)) {
array_map('unlink', glob("$tmpDir/*"));
rmdir($tmpDir);
http_response_code(500);
echo json_encode(['error' => 'Pitch shift failed', 'details' => implode("\n", $rbOutput_log)]);
exit;
}
// Convert back to original format
if ($ext === 'wav') {
$finalFile = $outputWav;
} else {
$ffmpegOut2 = escapeshellarg($outputFile);
$ffmpegIn2 = escapeshellarg($outputWav);
// High quality encoding
if ($ext === 'mp3') {
exec("ffmpeg -y -i $ffmpegIn2 -codec:a libmp3lame -qscale:a 0 $ffmpegOut2 2>&1");
} elseif ($ext === 'flac') {
exec("ffmpeg -y -i $ffmpegIn2 -codec:a flac $ffmpegOut2 2>&1");
} else {
exec("ffmpeg -y -i $ffmpegIn2 $ffmpegOut2 2>&1");
}
$finalFile = file_exists($outputFile) ? $outputFile : $outputWav;
}
// Send file
$outputName = $originalName . '_A' . $targetHz . '.' . $ext;
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $outputName . '"');
header('Content-Length: ' . filesize($finalFile));
readfile($finalFile);
// Cleanup
array_map('unlink', glob("$tmpDir/*"));
rmdir($tmpDir);
?>