🔍 Quellcode — playbush.de Toolbox

Alle serverseitigen Scripts zur Transparenz einsehbar. Keine Daten werden gespeichert oder weitergeleitet. — ← Zurück zur Toolbox

BPM Detector (Python) — bpm_detect.py

#!/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])))

BPM Server (PHP) — bpm_server.php

<?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);
?>

Chord Detector (Python) — chord_detect.py

#!/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])))

Chord Server (PHP) — chord_server.php

<?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);
?>

Stem Separator Start (PHP) — stem_start.php

<?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']);
?>

Stem Separator Status (PHP) — stem_status.php

<?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]);
?>

Pitch Shifter Server (PHP) — pitch_server.php

<?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);
?>