674cf9fd04
Neue Features: - Loop-Funktion mit visuellen Start/End-Markern - Reverse-Playback für Sample-Manipulation - ADSR-Envelope (Attack, Decay, Sustain, Release) - One-Shot vs Sustained-Mode - Pan-Control für Stereo-Positionierung - Sample-Rate-Reduktion (Lo-Fi-Effekt) - Fine-Tune (±100 Cents) - Stereo-Width-Control - Normalize-Funktion - Verbesserte Waveform-Visualisierung - Drag & Drop Loop-Marker - LFO-Speed-Control - Grain Chaos/Jitter - Bit-Crush-Effekt UI-Verbesserungen: - 3-Tab-Layout (Sampler, Synth, FX) - Toggle-Buttons für Modi - Erweiterte Parameter-Anzeigen - Loop-Region-Visualisierung - Verbesserte Keyboard-Shortcuts
1193 lines
55 KiB
PHP
1193 lines
55 KiB
PHP
<?php
|
|
// ----------------------------------------------------------------------------
|
|
// BACKEND: CRYPTO & DATA LAYER
|
|
// ----------------------------------------------------------------------------
|
|
$btcPrice = 68500; $trend = 'bullish'; // Fallback defaults
|
|
$ctx = stream_context_create(['http' => ['timeout' => 1.5, 'ignore_errors' => true]]);
|
|
|
|
try {
|
|
$json = @file_get_contents('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd&include_24hr_change=true', false, $ctx);
|
|
if($json) {
|
|
$d = json_decode($json, true);
|
|
$btcPrice = $d['bitcoin']['usd'];
|
|
$trend = ($d['bitcoin']['usd_24h_change'] >= 0) ? 'bullish' : 'bearish';
|
|
}
|
|
} catch(Exception $e) {}
|
|
|
|
$serverData = ['price' => $btcPrice, 'trend' => $trend];
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>OMNI-STATION V14 PRO // ADVANCED SAMPLER</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;900&family=Roboto+Mono:wght@400;700&display=swap" rel="stylesheet">
|
|
|
|
<style>
|
|
:root {
|
|
--c-accent: #00f3ff;
|
|
--c-warn: #ff0055;
|
|
--c-gold: #ffd700;
|
|
--c-green: #00ff88;
|
|
--bg-dark: #050508;
|
|
--panel: #121216;
|
|
}
|
|
body {
|
|
background: var(--bg-dark);
|
|
color: #ccc;
|
|
font-family: 'Roboto Mono', monospace;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
background-image: radial-gradient(circle at 50% 20%, #1a2030 0%, #000 80%);
|
|
}
|
|
|
|
/* PRO SLIDER DESIGN */
|
|
input[type=range] {
|
|
-webkit-appearance: none; width: 100%; background: transparent; height: 24px; cursor: pointer; position: relative; z-index: 10;
|
|
}
|
|
input[type=range]:focus { outline: none; }
|
|
input[type=range]::-webkit-slider-runnable-track {
|
|
width: 100%; height: 6px; background: #222; border: 1px solid #444; border-radius: 2px;
|
|
}
|
|
input[type=range]::-webkit-slider-thumb {
|
|
-webkit-appearance: none; height: 18px; width: 10px; background: var(--c-accent);
|
|
margin-top: -7px; border: 1px solid #fff; box-shadow: 0 0 10px var(--c-accent);
|
|
}
|
|
|
|
/* PANELS & UI */
|
|
.cyber-panel {
|
|
background: rgba(18, 18, 22, 0.9);
|
|
backdrop-filter: blur(12px);
|
|
border: 1px solid rgba(255,255,255,0.15);
|
|
box-shadow: 0 10px 40px rgba(0,0,0,0.7);
|
|
}
|
|
.tab-btn {
|
|
transition: all 0.2s; border-bottom: 3px solid transparent; opacity: 0.6;
|
|
}
|
|
.tab-btn.active {
|
|
opacity: 1; border-color: var(--c-accent); color: var(--c-accent); background: rgba(0,243,255,0.05);
|
|
text-shadow: 0 0 15px var(--c-accent);
|
|
}
|
|
|
|
/* VISUALIZER */
|
|
.scrub-line {
|
|
position: absolute; top: 0; bottom: 0; width: 2px; background: var(--c-warn);
|
|
box-shadow: 0 0 10px var(--c-warn); pointer-events: none; z-index: 20;
|
|
}
|
|
.loop-marker {
|
|
position: absolute; top: 0; bottom: 0; width: 3px; cursor: ew-resize; z-index: 25;
|
|
transition: box-shadow 0.2s;
|
|
}
|
|
.loop-marker:hover {
|
|
box-shadow: 0 0 20px currentColor;
|
|
}
|
|
.loop-region {
|
|
position: absolute; top: 0; bottom: 0; background: rgba(0, 255, 136, 0.1);
|
|
border-left: 2px solid var(--c-green); border-right: 2px solid var(--c-green);
|
|
pointer-events: none; z-index: 15;
|
|
}
|
|
|
|
/* KEYBOARD */
|
|
.key {
|
|
background: #eee; border-radius: 0 0 4px 4px; transition: 0.05s;
|
|
box-shadow: inset 0 -5px 10px rgba(0,0,0,0.2);
|
|
}
|
|
.key.black {
|
|
background: #111; color: #fff; z-index:10; height: 60%;
|
|
box-shadow: 3px 3px 5px rgba(0,0,0,0.5);
|
|
}
|
|
.key.active {
|
|
background: var(--c-accent) !important; transform: translateY(2px);
|
|
box-shadow: 0 0 25px var(--c-accent);
|
|
}
|
|
|
|
/* TOGGLE SWITCHES */
|
|
.toggle-btn {
|
|
@apply px-3 py-1 text-[10px] font-bold border-2 rounded cursor-pointer transition-all;
|
|
}
|
|
.toggle-btn.on {
|
|
background: var(--c-green); border-color: var(--c-green); color: #000;
|
|
box-shadow: 0 0 15px var(--c-green);
|
|
}
|
|
.toggle-btn.off {
|
|
background: transparent; border-color: #444; color: #666;
|
|
}
|
|
|
|
[x-cloak] { display: none !important; }
|
|
</style>
|
|
</head>
|
|
<body x-data="hybridEngine(<?= htmlspecialchars(json_encode($serverData)) ?>)"
|
|
class="h-screen w-screen flex flex-col p-3 gap-3"
|
|
@keydown.window="handleKey($event, true)"
|
|
@keyup.window="handleKey($event, false)">
|
|
|
|
<!-- HEADER -->
|
|
<header class="flex justify-between items-center px-6 py-3 cyber-panel rounded-t-lg z-50">
|
|
<div class="flex items-center gap-6">
|
|
<h1 class="text-3xl font-black font-[Orbitron] italic tracking-widest text-white">
|
|
OMNI<span class="text-[var(--c-accent)]">STATION</span> <span class="text-sm text-gray-500 not-italic">V14 PRO</span>
|
|
</h1>
|
|
<div class="h-8 w-px bg-gray-700"></div>
|
|
<div class="text-[10px] leading-tight font-bold text-gray-400">
|
|
<div>BTC: <span class="text-[var(--c-gold)]">$<span x-text="data.price"></span></span></div>
|
|
<div :class="data.trend==='bullish'?'text-green-500':'text-red-500'" x-text="data.trend.toUpperCase()"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-6">
|
|
<div class="text-right">
|
|
<div class="text-[9px] text-gray-500 mb-1">OUTPUT LEVEL</div>
|
|
<div class="w-32 h-2 bg-gray-900 rounded overflow-hidden border border-gray-700">
|
|
<div class="h-full bg-gradient-to-r from-green-500 to-red-500 transition-all duration-75" :style="`width: ${vuMeter}%`"></div>
|
|
</div>
|
|
</div>
|
|
<button @click="toggleSystem()"
|
|
:class="ready ? 'border-[var(--c-warn)] text-[var(--c-warn)] shadow-[0_0_20px_rgba(255,0,85,0.4)]' : 'border-[var(--c-accent)] text-[var(--c-accent)] animate-pulse'"
|
|
class="border-2 px-8 py-2 font-bold rounded uppercase tracking-widest hover:bg-white/5 transition-all">
|
|
<span x-text="ready ? 'SHUTDOWN' : 'INITIALIZE'"></span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- TAB NAVIGATION -->
|
|
<nav class="flex gap-2 bg-black/30 p-1 rounded cyber-panel z-40">
|
|
<button class="flex-1 py-2 font-bold text-xs tab-btn rounded" :class="tab==='main' && 'active'" @click="tab='main'">1. ADVANCED SAMPLER</button>
|
|
<button class="flex-1 py-2 font-bold text-xs tab-btn rounded" :class="tab==='synth' && 'active'" @click="tab='synth'">2. SYNTH ENGINE</button>
|
|
<button class="flex-1 py-2 font-bold text-xs tab-btn rounded" :class="tab==='fx' && 'active'" @click="tab='fx'">3. FX RACK & MASTER</button>
|
|
</nav>
|
|
|
|
<!-- MAIN CONTENT -->
|
|
<main class="flex-1 relative min-h-0 cyber-panel rounded-b-lg overflow-hidden z-30">
|
|
|
|
<!-- TAB 1: ADVANCED SAMPLER -->
|
|
<div x-show="tab==='main'" class="absolute inset-0 p-4 grid grid-cols-12 gap-4" x-transition>
|
|
|
|
<!-- LEFT: SAMPLER CONTROLS -->
|
|
<div class="col-span-3 flex flex-col gap-3 bg-white/5 p-4 rounded border border-white/10 overflow-y-auto">
|
|
|
|
<!-- PLAYBACK MODE -->
|
|
<div class="border-b border-white/10 pb-3">
|
|
<h3 class="text-[var(--c-green)] font-bold text-xs mb-2">PLAYBACK MODE</h3>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<button @click="p.oneShot = false"
|
|
:class="!p.oneShot ? 'on' : 'off'"
|
|
class="toggle-btn">SUSTAINED</button>
|
|
<button @click="p.oneShot = true"
|
|
:class="p.oneShot ? 'on' : 'off'"
|
|
class="toggle-btn">ONE-SHOT</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LOOP CONTROLS -->
|
|
<div class="border-b border-white/10 pb-3">
|
|
<h3 class="text-[var(--c-green)] font-bold text-xs mb-2">LOOP CONTROLS</h3>
|
|
<div class="space-y-2">
|
|
<div class="flex gap-2">
|
|
<button @click="p.loopEnabled = !p.loopEnabled"
|
|
:class="p.loopEnabled ? 'on' : 'off'"
|
|
class="toggle-btn flex-1">LOOP</button>
|
|
<button @click="p.reverse = !p.reverse"
|
|
:class="p.reverse ? 'on' : 'off'"
|
|
class="toggle-btn flex-1">REVERSE</button>
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] block">LOOP START</label>
|
|
<input type="range" min="0" max="1" step="0.001" x-model="p.loopStart" @input="validateLoopPoints()">
|
|
<div class="text-[8px] text-gray-500" x-text="Math.round(p.loopStart*100)+'%'"></div>
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] block">LOOP END</label>
|
|
<input type="range" min="0" max="1" step="0.001" x-model="p.loopEnd" @input="validateLoopPoints()">
|
|
<div class="text-[8px] text-gray-500" x-text="Math.round(p.loopEnd*100)+'%'"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ADSR ENVELOPE -->
|
|
<div class="border-b border-white/10 pb-3">
|
|
<h3 class="text-[var(--c-gold)] font-bold text-xs mb-2">ADSR ENVELOPE</h3>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>ATTACK</span><span x-text="(p.attack*1000).toFixed(0)+'ms'"></span></label>
|
|
<input type="range" min="0.001" max="2" step="0.001" x-model="p.attack">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>DECAY</span><span x-text="(p.decay*1000).toFixed(0)+'ms'"></span></label>
|
|
<input type="range" min="0.001" max="2" step="0.001" x-model="p.decay">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>SUSTAIN</span><span x-text="Math.round(p.sustain*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.sustain">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>RELEASE</span><span x-text="(p.release*1000).toFixed(0)+'ms'"></span></label>
|
|
<input type="range" min="0.001" max="5" step="0.001" x-model="p.release">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PITCH & TUNING -->
|
|
<div class="border-b border-white/10 pb-3">
|
|
<h3 class="text-[var(--c-accent)] font-bold text-xs mb-2">PITCH & TUNING</h3>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>PITCH</span><span x-text="p.pitch.toFixed(2)+'x'"></span></label>
|
|
<input type="range" min="0.25" max="4" step="0.01" x-model="p.pitch">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>FINE TUNE</span><span x-text="p.fineTune+' cents'"></span></label>
|
|
<input type="range" min="-100" max="100" step="1" x-model="p.fineTune">
|
|
</div>
|
|
<button @click="p.pitch=1; p.fineTune=0" class="w-full py-1 text-[9px] bg-white/10 hover:bg-white/20 rounded">RESET PITCH</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- PAN & STEREO -->
|
|
<div class="border-b border-white/10 pb-3">
|
|
<h3 class="text-purple-400 font-bold text-xs mb-2">STEREO</h3>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between">
|
|
<span>PAN</span>
|
|
<span x-text="p.pan === 0 ? 'CENTER' : (p.pan > 0 ? 'R'+Math.round(p.pan*100) : 'L'+Math.round(Math.abs(p.pan)*100))"></span>
|
|
</label>
|
|
<input type="range" min="-1" max="1" step="0.01" x-model="p.pan" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>STEREO WIDTH</span><span x-text="Math.round(p.width*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="2" step="0.01" x-model="p.width">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LO-FI / DEGRADATION -->
|
|
<div>
|
|
<h3 class="text-orange-400 font-bold text-xs mb-2">LO-FI / DEGRADATION</h3>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>SAMPLE RATE</span><span x-text="Math.round((1-p.bitCrush)*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="0.95" step="0.01" x-model="p.bitCrush">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>GRAIN CHAOS</span><span x-text="Math.round(p.jitter*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="0.5" step="0.01" x-model="p.jitter">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CENTER-RIGHT: WAVEFORM & CONTROLS -->
|
|
<div class="col-span-9 flex flex-col gap-4">
|
|
|
|
<!-- WAVEFORM DISPLAY -->
|
|
<div class="flex-1 bg-black rounded border border-white/10 relative group overflow-hidden cursor-crosshair"
|
|
@mousedown="scrubStart($event)" @mousemove="scrubMove($event)" @mouseup="scrubEnd()" @mouseleave="scrubEnd()"
|
|
@dragover.prevent @drop.prevent="handleDrop($event)">
|
|
|
|
<!-- CANVAS -->
|
|
<canvas id="wave" class="absolute inset-0 w-full h-full opacity-80"></canvas>
|
|
|
|
<!-- LOOP REGION -->
|
|
<div x-show="p.loopEnabled && buffer"
|
|
class="loop-region"
|
|
:style="`left: ${p.loopStart * 100}%; width: ${(p.loopEnd - p.loopStart) * 100}%`"></div>
|
|
|
|
<!-- LOOP START MARKER -->
|
|
<div x-show="p.loopEnabled && buffer"
|
|
class="loop-marker"
|
|
:style="`left: ${p.loopStart * 100}%; background: var(--c-green); color: var(--c-green)`"
|
|
@mousedown.stop="dragLoopStart($event)">
|
|
</div>
|
|
|
|
<!-- LOOP END MARKER -->
|
|
<div x-show="p.loopEnabled && buffer"
|
|
class="loop-marker"
|
|
:style="`left: ${p.loopEnd * 100}%; background: var(--c-green); color: var(--c-green)`"
|
|
@mousedown.stop="dragLoopEnd($event)">
|
|
</div>
|
|
|
|
<!-- PLAYHEAD -->
|
|
<div class="scrub-line" :style="`left: ${p.samplePos * 100}%`"></div>
|
|
|
|
<!-- INFO OVERLAY -->
|
|
<div class="absolute top-3 left-3 pointer-events-none space-y-1">
|
|
<div class="text-[11px] font-bold text-[var(--c-accent)]" x-text="fileName || 'NO FILE LOADED - DRAG MP3/WAV HERE'"></div>
|
|
<div class="text-[9px] text-gray-400" x-show="buffer">
|
|
<span x-text="(buffer?.duration || 0).toFixed(2)+'s'"></span> ·
|
|
<span x-text="(buffer?.sampleRate || 0)/1000+'kHz'"></span> ·
|
|
<span x-text="buffer?.numberOfChannels + ' CH'"></span>
|
|
</div>
|
|
<div class="text-[9px] text-[var(--c-warn)] animate-pulse" x-show="isScrubbing">⚡ SCRUBBING</div>
|
|
<div class="text-[9px] text-[var(--c-green)]" x-show="p.loopEnabled">🔁 LOOP: <span x-text="Math.round(p.loopStart*100)+'% → '+Math.round(p.loopEnd*100)+'%'"></span></div>
|
|
</div>
|
|
|
|
<!-- FILE INPUT -->
|
|
<div class="absolute inset-0 flex items-center justify-center pointer-events-none" x-show="!buffer">
|
|
<div class="text-center">
|
|
<div class="text-6xl mb-4 opacity-20">📁</div>
|
|
<div class="text-sm text-gray-500">DRAG & DROP AUDIO FILE</div>
|
|
<div class="text-xs text-gray-600 mt-2">OR CLICK TO BROWSE</div>
|
|
</div>
|
|
</div>
|
|
<input type="file" accept="audio/*" class="absolute inset-0 opacity-0 cursor-pointer" @change="loadFile($event)" x-show="!buffer">
|
|
</div>
|
|
|
|
<!-- BOTTOM CONTROLS -->
|
|
<div class="grid grid-cols-4 gap-3 bg-white/5 p-4 rounded border border-white/10">
|
|
|
|
<!-- VOLUME & MIX -->
|
|
<div>
|
|
<h4 class="text-[10px] text-[var(--c-gold)] font-bold mb-2">VOLUME & MIX</h4>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>SAMPLE VOL</span><span x-text="Math.round(p.sampleVol*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1.5" step="0.01" x-model="p.sampleVol">
|
|
</div>
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>SYNTH MIX</span><span x-text="Math.round(p.synthVol*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.synthVol">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FILTER -->
|
|
<div>
|
|
<h4 class="text-[10px] text-[var(--c-accent)] font-bold mb-2">FILTER</h4>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>CUTOFF</span><span x-text="Math.round(p.cutoff*100)+'%'"></span></label>
|
|
<input type="range" min="0.1" max="1" step="0.01" x-model="p.cutoff" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>RESONANCE</span><span x-text="Math.round(p.reso*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.reso" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LFO WOBBLE -->
|
|
<div>
|
|
<h4 class="text-[10px] text-purple-400 font-bold mb-2">LFO MODULATION</h4>
|
|
<div class="space-y-2">
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>WOBBLE AMT</span><span x-text="Math.round(p.wobble*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.wobble" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[8px] flex justify-between"><span>LFO SPEED</span><span x-text="p.lfoSpeed.toFixed(1)+'Hz'"></span></label>
|
|
<input type="range" min="0.1" max="20" step="0.1" x-model="p.lfoSpeed" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ACTIONS -->
|
|
<div>
|
|
<h4 class="text-[10px] text-gray-400 font-bold mb-2">ACTIONS</h4>
|
|
<div class="space-y-1">
|
|
<button @click="normalizeBuffer()" :disabled="!buffer" class="w-full py-1 text-[9px] bg-blue-500/20 hover:bg-blue-500/40 disabled:opacity-30 disabled:cursor-not-allowed rounded border border-blue-500/50">
|
|
NORMALIZE
|
|
</button>
|
|
<button @click="resetSampler()" class="w-full py-1 text-[9px] bg-red-500/20 hover:bg-red-500/40 rounded border border-red-500/50">
|
|
RESET ALL
|
|
</button>
|
|
<div class="text-[8px] text-gray-600 mt-2">
|
|
Use A-K keys to play<br>
|
|
Arrows: Dist/Scrub
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB 2: SYNTH ENGINE -->
|
|
<div x-show="tab==='synth'" class="absolute inset-0 p-4 grid grid-cols-3 gap-4" x-transition>
|
|
<div class="bg-white/5 p-4 rounded border border-white/10">
|
|
<h3 class="text-[var(--c-accent)] font-bold text-sm mb-4 border-b border-white/10 pb-2">OSC A (UNISON)</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>SYNTH MIX</span><span x-text="Math.round(p.synthVol*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.synthVol">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>DETUNE</span><span x-text="p.detune+' cents'"></span></label>
|
|
<input type="range" min="0" max="50" step="1" x-model="p.detune">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>SUB OSC</span><span x-text="Math.round(p.subVol*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.subVol">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white/5 p-4 rounded border border-white/10">
|
|
<h3 class="text-[var(--c-gold)] font-bold text-sm mb-4 border-b border-white/10 pb-2">FILTER</h3>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>CUTOFF</span><span x-text="Math.round(p.cutoff*100)+'%'"></span></label>
|
|
<input type="range" min="0.1" max="1" step="0.01" x-model="p.cutoff" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] flex justify-between"><span>RESONANCE</span><span x-text="Math.round(p.reso*100)+'%'"></span></label>
|
|
<input type="range" min="0" max="1" step="0.01" x-model="p.reso" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-black/30 p-4 rounded text-[10px] text-gray-400 font-mono">
|
|
<div class="text-white font-bold mb-2">SYNTH INFO:</div>
|
|
<div class="space-y-1">
|
|
<div>• 3x Sawtooth Stack</div>
|
|
<div>• Unison Detuning</div>
|
|
<div>• Sub Oscillator</div>
|
|
<div>• Lowpass Filter</div>
|
|
<div>• LFO Modulation</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB 3: FX RACK -->
|
|
<div x-show="tab==='fx'" class="absolute inset-0 p-6 grid grid-cols-4 gap-6 bg-black/40" x-cloak>
|
|
<template x-for="mod in ['DISTORTION', 'DELAY', 'REVERB', 'MASTER']">
|
|
<div class="p-5 bg-[#18181c] border border-gray-700 rounded hover:border-[var(--c-accent)] transition-colors">
|
|
<h3 class="text-white font-bold text-xs mb-4 border-b border-gray-600 pb-2" x-text="mod"></h3>
|
|
|
|
<div x-show="mod==='DISTORTION'" class="space-y-4">
|
|
<div>
|
|
<label class="text-[9px] text-[var(--c-warn)] flex justify-between"><span>DRIVE</span><span x-text="Math.round(p.dist*100)+'%'"></span></label>
|
|
<input type="range" x-model="p.dist" min="0" max="1" step="0.01" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] text-[var(--c-warn)] flex justify-between"><span>CRUSH</span><span x-text="Math.round(p.crush*100)+'%'"></span></label>
|
|
<input type="range" x-model="p.crush" min="0" max="1" step="0.01" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="mod==='DELAY'" class="space-y-4">
|
|
<div>
|
|
<label class="text-[9px] text-green-400 flex justify-between"><span>FEEDBACK</span><span x-text="Math.round(p.delayFb*100)+'%'"></span></label>
|
|
<input type="range" x-model="p.delayFb" min="0" max="0.95" step="0.01" @input="updateParams()">
|
|
</div>
|
|
<div>
|
|
<label class="text-[9px] text-green-400 flex justify-between"><span>TIME</span><span x-text="(p.delayTime*1000).toFixed(0)+'ms'"></span></label>
|
|
<input type="range" x-model="p.delayTime" min="0.05" max="2" step="0.01" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="mod==='REVERB'" class="space-y-4">
|
|
<div>
|
|
<label class="text-[9px] text-purple-400 flex justify-between"><span>WET MIX</span><span x-text="Math.round(p.verbMix*100)+'%'"></span></label>
|
|
<input type="range" x-model="p.verbMix" min="0" max="1" step="0.01" @input="updateParams()">
|
|
</div>
|
|
</div>
|
|
|
|
<div x-show="mod==='MASTER'" class="space-y-4">
|
|
<div>
|
|
<label class="text-[9px] text-white flex justify-between"><span>MAIN OUTPUT</span><span x-text="Math.round(p.master*100)+'%'"></span></label>
|
|
<input type="range" x-model="p.master" min="0" max="1.5" step="0.01" @input="updateParams()">
|
|
</div>
|
|
<div class="text-[9px] text-gray-500 mt-4">
|
|
<div class="mb-1">🛡️ LIMITER ACTIVE</div>
|
|
<div>PEAK: <span class="text-[var(--c-accent)]" x-text="Math.round(vuMeter)+'%'"></span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- KEYBOARD FOOTER -->
|
|
<footer class="h-32 cyber-panel rounded-lg flex px-1 py-1 relative z-50 select-none">
|
|
<template x-for="n in whiteKeys">
|
|
<div class="flex-1 border-r border-gray-400 last:border-0 rounded-b cursor-pointer key relative bg-white text-black"
|
|
:class="activeNotes.has(n.midi) ? 'active' : ''"
|
|
@mousedown="play(n.midi)" @mouseup="stop(n.midi)" @mouseleave="stop(n.midi)" @mouseenter="if($event.buttons===1) play(n.midi)">
|
|
<span class="absolute bottom-2 left-1/2 -translate-x-1/2 text-[10px] font-bold" x-text="n.key"></span>
|
|
</div>
|
|
</template>
|
|
<template x-for="n in blackKeys">
|
|
<div class="absolute w-[6%] rounded-b cursor-pointer key black border-x border-b border-white/20"
|
|
:style="`left: ${n.pos}%`" :class="activeNotes.has(n.midi) ? 'active' : ''"
|
|
@mousedown="play(n.midi)" @mouseup="stop(n.midi)" @mouseleave="stop(n.midi)" @mouseenter="if($event.buttons===1) play(n.midi)">
|
|
</div>
|
|
</template>
|
|
</footer>
|
|
|
|
<script>
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.data('hybridEngine', (server) => ({
|
|
data: server,
|
|
ready: false,
|
|
tab: 'main',
|
|
ctx: null,
|
|
|
|
// State
|
|
vuMeter: 0,
|
|
activeNotes: new Set(),
|
|
fileName: '',
|
|
buffer: null,
|
|
originalBuffer: null,
|
|
|
|
// Scrubbing & Loop Dragging
|
|
isScrubbing: false,
|
|
isDraggingLoopStart: false,
|
|
isDraggingLoopEnd: false,
|
|
scrubNode: null,
|
|
lastMouseX: 0,
|
|
|
|
// Parameters
|
|
p: {
|
|
master: 0.8,
|
|
|
|
// Synth
|
|
synthVol: 0.6, subVol: 0.4, detune: 12,
|
|
|
|
// Filter
|
|
cutoff: 1.0, reso: 0.0,
|
|
|
|
// Sampler Basic
|
|
sampleVol: 0.8,
|
|
samplePos: 0.0,
|
|
pitch: 1.0,
|
|
fineTune: 0,
|
|
|
|
// Sampler Advanced
|
|
oneShot: false,
|
|
loopEnabled: false,
|
|
loopStart: 0.0,
|
|
loopEnd: 1.0,
|
|
reverse: false,
|
|
|
|
// ADSR
|
|
attack: 0.005,
|
|
decay: 0.1,
|
|
sustain: 0.7,
|
|
release: 0.2,
|
|
|
|
// Stereo
|
|
pan: 0,
|
|
width: 1.0,
|
|
|
|
// Lo-Fi
|
|
bitCrush: 0,
|
|
jitter: 0.0,
|
|
|
|
// LFO
|
|
wobble: 0.0,
|
|
lfoSpeed: 4,
|
|
|
|
// FX
|
|
dist: 0,
|
|
crush: 0,
|
|
delayFb: 0.4,
|
|
delayTime: 0.3,
|
|
verbMix: 0.3
|
|
},
|
|
|
|
// Keyboard Map
|
|
whiteKeys: [
|
|
{key:'A',midi:60},{key:'S',midi:62},{key:'D',midi:64},{key:'F',midi:65},
|
|
{key:'G',midi:67},{key:'H',midi:69},{key:'J',midi:71},{key:'K',midi:72}
|
|
],
|
|
blackKeys: [
|
|
{key:'W',midi:61,pos:10.5},{key:'E',midi:63,pos:23},
|
|
{key:'T',midi:66,pos:48},{key:'Z',midi:68,pos:60.5},{key:'U',midi:70,pos:73}
|
|
],
|
|
|
|
nodes: {}, voices: {}, analyser: null,
|
|
|
|
init() {
|
|
this.loopVis();
|
|
|
|
// Global mouse handlers for loop dragging
|
|
document.addEventListener('mousemove', (e) => {
|
|
if(this.isDraggingLoopStart || this.isDraggingLoopEnd) {
|
|
const canvas = document.getElementById('wave');
|
|
if(!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
|
|
if(this.isDraggingLoopStart) {
|
|
this.p.loopStart = Math.min(pos, this.p.loopEnd - 0.01);
|
|
} else if(this.isDraggingLoopEnd) {
|
|
this.p.loopEnd = Math.max(pos, this.p.loopStart + 0.01);
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
this.isDraggingLoopStart = false;
|
|
this.isDraggingLoopEnd = false;
|
|
});
|
|
},
|
|
|
|
async toggleSystem() {
|
|
if(!this.ctx) {
|
|
const AC = window.AudioContext || window.webkitAudioContext;
|
|
this.ctx = new AC();
|
|
await this.buildGraph();
|
|
this.generateFallbackBuffer();
|
|
}
|
|
if(this.ctx.state === 'suspended') await this.ctx.resume();
|
|
this.ready = !this.ready;
|
|
|
|
if(this.p.cutoff < 0.1) this.p.cutoff = 0.8;
|
|
this.updateParams();
|
|
|
|
this.nodes.master.gain.setTargetAtTime(this.ready ? this.p.master : 0, this.ctx.currentTime, 0.1);
|
|
if(!this.ready) this.stopAll();
|
|
},
|
|
|
|
async buildGraph() {
|
|
const c = this.ctx;
|
|
|
|
// INPUT & FILTER
|
|
this.nodes.input = c.createGain();
|
|
this.nodes.filter = c.createBiquadFilter();
|
|
this.nodes.filter.type = 'lowpass';
|
|
this.nodes.filter.frequency.value = 20000;
|
|
|
|
// LFO (Wobble)
|
|
this.nodes.lfo = c.createOscillator();
|
|
this.nodes.lfo.frequency.value = 4;
|
|
this.nodes.lfoGain = c.createGain();
|
|
this.nodes.lfoGain.gain.value = 0;
|
|
this.nodes.lfo.connect(this.nodes.lfoGain).connect(this.nodes.filter.frequency);
|
|
this.nodes.lfo.start();
|
|
|
|
// STEREO PANNER
|
|
this.nodes.panner = c.createStereoPanner ? c.createStereoPanner() : c.createGain();
|
|
|
|
// FX CHAIN
|
|
this.nodes.dist = c.createWaveShaper();
|
|
this.nodes.dist.curve = this.makeCurve(0,0);
|
|
|
|
this.nodes.delay = c.createDelay(5);
|
|
this.nodes.dfb = c.createGain();
|
|
this.nodes.delay.connect(this.nodes.dfb).connect(this.nodes.delay);
|
|
this.nodes.dgain = c.createGain();
|
|
this.nodes.dgain.gain.value = 0.5;
|
|
|
|
this.nodes.verb = c.createConvolver();
|
|
await this.makeReverb();
|
|
this.nodes.vgain = c.createGain();
|
|
|
|
this.nodes.master = c.createGain();
|
|
this.nodes.analyser = c.createAnalyser();
|
|
this.nodes.analyser.fftSize = 1024;
|
|
|
|
// ROUTING
|
|
this.nodes.input.connect(this.nodes.filter);
|
|
|
|
if(this.nodes.panner.pan) {
|
|
this.nodes.filter.connect(this.nodes.panner);
|
|
this.nodes.panner.connect(this.nodes.dist);
|
|
} else {
|
|
this.nodes.filter.connect(this.nodes.dist);
|
|
}
|
|
|
|
this.nodes.dist.connect(this.nodes.master);
|
|
this.nodes.dist.connect(this.nodes.delay);
|
|
this.nodes.delay.connect(this.nodes.dgain).connect(this.nodes.master);
|
|
this.nodes.dist.connect(this.nodes.verb);
|
|
this.nodes.verb.connect(this.nodes.vgain).connect(this.nodes.master);
|
|
|
|
this.nodes.master.connect(this.nodes.analyser);
|
|
this.nodes.analyser.connect(c.destination);
|
|
},
|
|
|
|
// ---------------------------
|
|
// PLAY ENGINE (Advanced Sampler)
|
|
// ---------------------------
|
|
play(midi, velocity = 0.8) {
|
|
if(!this.ready) return;
|
|
if(this.activeNotes.has(midi) && !this.p.oneShot) return;
|
|
this.activeNotes.add(midi);
|
|
|
|
const t = this.ctx.currentTime;
|
|
const freq = 440 * Math.pow(2, (midi-69)/12);
|
|
const v = { oscs: [], env: this.ctx.createGain(), midi: midi };
|
|
|
|
// A. SYNTH LAYER
|
|
if(this.p.synthVol > 0) {
|
|
for(let i=0; i<3; i++) {
|
|
const o = this.ctx.createOscillator();
|
|
o.type = 'sawtooth';
|
|
o.frequency.value = freq;
|
|
o.detune.value = (i-1) * this.p.detune;
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = (this.p.synthVol / 3) * 0.5 * velocity;
|
|
o.connect(g).connect(v.env);
|
|
o.start(t);
|
|
v.oscs.push(o);
|
|
}
|
|
}
|
|
|
|
// Sub Osc
|
|
if(this.p.subVol > 0) {
|
|
const s = this.ctx.createOscillator();
|
|
s.type = 'sine';
|
|
s.frequency.value = freq/2;
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = this.p.subVol * velocity;
|
|
s.connect(g).connect(v.env);
|
|
s.start(t);
|
|
v.oscs.push(s);
|
|
}
|
|
|
|
// B. ADVANCED SAMPLER LAYER
|
|
if(this.buffer && this.p.sampleVol > 0) {
|
|
const s = this.ctx.createBufferSource();
|
|
|
|
// Use reversed buffer if reverse is enabled
|
|
s.buffer = this.p.reverse ? this.getReverseBuffer() : this.buffer;
|
|
|
|
// Pitch with fine tune
|
|
const pitchMult = Math.pow(2, this.p.fineTune / 1200);
|
|
s.playbackRate.value = (freq/261.63) * this.p.pitch * pitchMult;
|
|
|
|
// Loop settings
|
|
if(this.p.loopEnabled && !this.p.oneShot) {
|
|
s.loop = true;
|
|
const dur = s.buffer.duration;
|
|
s.loopStart = this.p.loopStart * dur;
|
|
s.loopEnd = this.p.loopEnd * dur;
|
|
}
|
|
|
|
// Sample rate reduction (bit crush)
|
|
let sampleNode = s;
|
|
if(this.p.bitCrush > 0) {
|
|
const crusher = this.ctx.createWaveShaper();
|
|
const samples = Math.max(2, Math.floor(256 * (1 - this.p.bitCrush)));
|
|
crusher.curve = this.makeBitCrushCurve(samples);
|
|
s.connect(crusher);
|
|
sampleNode = crusher;
|
|
}
|
|
|
|
// Granular Start Position
|
|
let start = this.p.samplePos * s.buffer.duration;
|
|
start += (Math.random() - 0.5) * this.p.jitter;
|
|
start = Math.max(0, Math.min(start, s.buffer.duration - 0.01));
|
|
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = this.p.sampleVol * velocity;
|
|
|
|
if(sampleNode === s) {
|
|
s.connect(g).connect(v.env);
|
|
} else {
|
|
sampleNode.connect(g).connect(v.env);
|
|
}
|
|
|
|
// Start playback
|
|
if(this.p.oneShot) {
|
|
s.start(t, start);
|
|
// Auto-stop after sample duration
|
|
const duration = s.buffer.duration - start;
|
|
setTimeout(() => this.stop(midi), duration * 1000 / s.playbackRate.value);
|
|
} else {
|
|
s.start(t, start);
|
|
}
|
|
|
|
v.oscs.push(s);
|
|
}
|
|
|
|
// ADSR ENVELOPE
|
|
const att = this.p.attack;
|
|
const dec = this.p.decay;
|
|
const sus = this.p.sustain;
|
|
|
|
v.env.gain.setValueAtTime(0, t);
|
|
v.env.gain.linearRampToValueAtTime(1.0, t + att);
|
|
v.env.gain.linearRampToValueAtTime(sus, t + att + dec);
|
|
|
|
v.env.connect(this.nodes.input);
|
|
this.voices[midi] = v;
|
|
|
|
// Auto-stop for one-shot mode
|
|
if(this.p.oneShot && this.buffer) {
|
|
const totalDuration = att + dec + (this.buffer.duration * 0.5);
|
|
setTimeout(() => this.stop(midi), totalDuration * 1000);
|
|
}
|
|
},
|
|
|
|
stop(midi) {
|
|
if(!this.voices[midi]) return;
|
|
this.activeNotes.delete(midi);
|
|
const v = this.voices[midi];
|
|
const t = this.ctx.currentTime;
|
|
|
|
// Release phase
|
|
v.env.gain.cancelScheduledValues(t);
|
|
v.env.gain.setValueAtTime(v.env.gain.value, t);
|
|
v.env.gain.linearRampToValueAtTime(0, t + this.p.release);
|
|
|
|
setTimeout(() => {
|
|
v.oscs.forEach(o => {try{o.stop()}catch(e){}});
|
|
v.env.disconnect();
|
|
}, this.p.release * 1000 + 50);
|
|
|
|
delete this.voices[midi];
|
|
},
|
|
|
|
stopAll() {
|
|
Array.from(this.activeNotes).forEach(n => this.stop(n));
|
|
},
|
|
|
|
// ---------------------------
|
|
// SCRUBBING & LOOP DRAGGING
|
|
// ---------------------------
|
|
scrubStart(e) {
|
|
if(!this.buffer || !this.ready) return;
|
|
this.isScrubbing = true;
|
|
this.lastMouseX = e.clientX;
|
|
this.stopAll();
|
|
|
|
if(this.scrubNode) this.scrubNode.stop();
|
|
this.scrubNode = this.ctx.createBufferSource();
|
|
this.scrubNode.buffer = this.buffer;
|
|
this.scrubNode.loop = true;
|
|
const g = this.ctx.createGain();
|
|
g.gain.value = this.p.sampleVol;
|
|
this.scrubNode.connect(g).connect(this.nodes.input);
|
|
this.scrubNode.start(0, this.p.samplePos * this.buffer.duration);
|
|
this.scrubNode.playbackRate.value = 0;
|
|
},
|
|
|
|
scrubMove(e) {
|
|
if(!this.isScrubbing) return;
|
|
const r = e.target.getBoundingClientRect();
|
|
this.p.samplePos = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
|
|
|
|
const delta = e.clientX - this.lastMouseX;
|
|
this.lastMouseX = e.clientX;
|
|
if(this.scrubNode) {
|
|
this.scrubNode.playbackRate.setTargetAtTime(delta * 0.3, this.ctx.currentTime, 0.05);
|
|
}
|
|
},
|
|
|
|
scrubEnd() {
|
|
this.isScrubbing = false;
|
|
if(this.scrubNode) {
|
|
this.scrubNode.stop();
|
|
this.scrubNode = null;
|
|
}
|
|
},
|
|
|
|
dragLoopStart(e) {
|
|
e.preventDefault();
|
|
this.isDraggingLoopStart = true;
|
|
},
|
|
|
|
dragLoopEnd(e) {
|
|
e.preventDefault();
|
|
this.isDraggingLoopEnd = true;
|
|
},
|
|
|
|
validateLoopPoints() {
|
|
if(this.p.loopStart >= this.p.loopEnd) {
|
|
this.p.loopEnd = Math.min(1, this.p.loopStart + 0.01);
|
|
}
|
|
},
|
|
|
|
// ---------------------------
|
|
// PARAMS & FX
|
|
// ---------------------------
|
|
updateParams() {
|
|
if(!this.ready) return;
|
|
const t = this.ctx.currentTime;
|
|
|
|
this.nodes.master.gain.setTargetAtTime(this.p.master, t, 0.1);
|
|
|
|
const f = Math.pow(this.p.cutoff, 2) * 20000;
|
|
this.nodes.filter.frequency.setTargetAtTime(Math.max(20, f), t, 0.1);
|
|
this.nodes.filter.Q.value = this.p.reso * 20;
|
|
|
|
// LFO
|
|
this.nodes.lfo.frequency.value = this.p.lfoSpeed;
|
|
this.nodes.lfoGain.gain.setTargetAtTime(this.p.wobble * 2000, t, 0.1);
|
|
|
|
// Panner
|
|
if(this.nodes.panner.pan) {
|
|
this.nodes.panner.pan.setTargetAtTime(this.p.pan, t, 0.1);
|
|
}
|
|
|
|
// Distortion
|
|
this.nodes.dist.curve = this.makeCurve(this.p.dist, this.p.crush);
|
|
|
|
// Delay
|
|
this.nodes.dfb.gain.value = this.p.delayFb;
|
|
this.nodes.delay.delayTime.value = this.p.delayTime;
|
|
|
|
// Reverb
|
|
this.nodes.vgain.gain.setTargetAtTime(this.p.verbMix, t, 0.1);
|
|
},
|
|
|
|
// ---------------------------
|
|
// BUFFER OPERATIONS
|
|
// ---------------------------
|
|
getReverseBuffer() {
|
|
if(!this.buffer) return null;
|
|
|
|
const reversed = this.ctx.createBuffer(
|
|
this.buffer.numberOfChannels,
|
|
this.buffer.length,
|
|
this.buffer.sampleRate
|
|
);
|
|
|
|
for(let ch = 0; ch < this.buffer.numberOfChannels; ch++) {
|
|
const input = this.buffer.getChannelData(ch);
|
|
const output = reversed.getChannelData(ch);
|
|
for(let i = 0; i < input.length; i++) {
|
|
output[i] = input[input.length - 1 - i];
|
|
}
|
|
}
|
|
|
|
return reversed;
|
|
},
|
|
|
|
normalizeBuffer() {
|
|
if(!this.buffer) return;
|
|
|
|
// Find peak
|
|
let peak = 0;
|
|
for(let ch = 0; ch < this.buffer.numberOfChannels; ch++) {
|
|
const data = this.buffer.getChannelData(ch);
|
|
for(let i = 0; i < data.length; i++) {
|
|
peak = Math.max(peak, Math.abs(data[i]));
|
|
}
|
|
}
|
|
|
|
if(peak === 0) return;
|
|
|
|
// Create normalized buffer
|
|
const normalized = this.ctx.createBuffer(
|
|
this.buffer.numberOfChannels,
|
|
this.buffer.length,
|
|
this.buffer.sampleRate
|
|
);
|
|
|
|
const ratio = 0.95 / peak; // Leave headroom
|
|
|
|
for(let ch = 0; ch < this.buffer.numberOfChannels; ch++) {
|
|
const input = this.buffer.getChannelData(ch);
|
|
const output = normalized.getChannelData(ch);
|
|
for(let i = 0; i < input.length; i++) {
|
|
output[i] = input[i] * ratio;
|
|
}
|
|
}
|
|
|
|
this.buffer = normalized;
|
|
this.drawWave();
|
|
},
|
|
|
|
resetSampler() {
|
|
this.p.samplePos = 0;
|
|
this.p.pitch = 1;
|
|
this.p.fineTune = 0;
|
|
this.p.loopStart = 0;
|
|
this.p.loopEnd = 1;
|
|
this.p.loopEnabled = false;
|
|
this.p.reverse = false;
|
|
this.p.oneShot = false;
|
|
this.p.pan = 0;
|
|
this.p.width = 1;
|
|
this.p.bitCrush = 0;
|
|
this.p.jitter = 0;
|
|
this.p.attack = 0.005;
|
|
this.p.decay = 0.1;
|
|
this.p.sustain = 0.7;
|
|
this.p.release = 0.2;
|
|
this.updateParams();
|
|
},
|
|
|
|
// ---------------------------
|
|
// FILES & UTILS
|
|
// ---------------------------
|
|
handleDrop(e) {
|
|
const f = e.dataTransfer.files[0];
|
|
if(f) this.loadFile({target:{files:[f]}});
|
|
},
|
|
|
|
loadFile(e) {
|
|
const f = e.target.files[0];
|
|
if(!f) return;
|
|
if(!this.ready) this.toggleSystem();
|
|
|
|
this.fileName = f.name.toUpperCase();
|
|
const r = new FileReader();
|
|
r.onload = ev => {
|
|
this.ctx.decodeAudioData(ev.target.result, b => {
|
|
this.buffer = b;
|
|
this.originalBuffer = b;
|
|
this.drawWave();
|
|
});
|
|
};
|
|
r.readAsArrayBuffer(f);
|
|
},
|
|
|
|
generateFallbackBuffer() {
|
|
const sr = this.ctx.sampleRate;
|
|
const b = this.ctx.createBuffer(2, sr * 2, sr);
|
|
|
|
for(let ch = 0; ch < 2; ch++) {
|
|
const d = b.getChannelData(ch);
|
|
for(let i = 0; i < d.length; i++) {
|
|
d[i] = (Math.random() * 2 - 1) * Math.exp(-i / sr * 2);
|
|
}
|
|
}
|
|
|
|
this.buffer = b;
|
|
this.originalBuffer = b;
|
|
this.fileName = 'FALLBACK NOISE';
|
|
this.drawWave();
|
|
},
|
|
|
|
makeCurve(amt, crush) {
|
|
const k = amt * 100;
|
|
const n = 256;
|
|
const c = new Float32Array(n);
|
|
|
|
for(let i = 0; i < n; i++) {
|
|
let x = (i * 2) / n - 1;
|
|
let y = (3 + k) * x * 20 * (Math.PI / 180) / (Math.PI + k * Math.abs(x));
|
|
|
|
if(crush > 0) {
|
|
const s = 2 + (1 - crush) * 20;
|
|
y = Math.round(y * s) / s;
|
|
}
|
|
|
|
c[i] = y;
|
|
}
|
|
|
|
return c;
|
|
},
|
|
|
|
makeBitCrushCurve(samples) {
|
|
const curve = new Float32Array(samples);
|
|
for(let i = 0; i < samples; i++) {
|
|
curve[i] = (i * 2 / samples) - 1;
|
|
}
|
|
return curve;
|
|
},
|
|
|
|
async makeReverb() {
|
|
const l = this.ctx.sampleRate * 3;
|
|
const b = this.ctx.createBuffer(2, l, this.ctx.sampleRate);
|
|
|
|
for(let ch = 0; ch < 2; ch++) {
|
|
const d = b.getChannelData(ch);
|
|
for(let i = 0; i < l; i++) {
|
|
const decay = Math.pow(1 - i / l, 2);
|
|
d[i] = (Math.random() * 2 - 1) * decay;
|
|
}
|
|
}
|
|
|
|
this.nodes.verb.buffer = b;
|
|
},
|
|
|
|
handleKey(e, down) {
|
|
if(e.repeat) return;
|
|
|
|
// Piano keys
|
|
const keys = "asdfghjk";
|
|
const idx = keys.indexOf(e.key.toLowerCase());
|
|
if(idx > -1) {
|
|
if(down) this.play(60 + idx);
|
|
else this.stop(60 + idx);
|
|
}
|
|
|
|
// Black keys
|
|
const blackMap = {w:61, e:63, t:66, z:68, u:70};
|
|
if(blackMap[e.key.toLowerCase()]) {
|
|
const m = blackMap[e.key.toLowerCase()];
|
|
if(down) this.play(m);
|
|
else this.stop(m);
|
|
}
|
|
|
|
// Arrows for FX/Scrub
|
|
if(down) {
|
|
if(e.key === 'ArrowUp') {
|
|
this.p.dist = Math.min(1, this.p.dist + 0.05);
|
|
this.updateParams();
|
|
}
|
|
if(e.key === 'ArrowDown') {
|
|
this.p.dist = Math.max(0, this.p.dist - 0.05);
|
|
this.updateParams();
|
|
}
|
|
if(e.key === 'ArrowRight') {
|
|
this.p.samplePos = Math.min(1, this.p.samplePos + 0.01);
|
|
}
|
|
if(e.key === 'ArrowLeft') {
|
|
this.p.samplePos = Math.max(0, this.p.samplePos - 0.01);
|
|
}
|
|
if(e.key === ' ') {
|
|
e.preventDefault();
|
|
this.stopAll();
|
|
}
|
|
}
|
|
},
|
|
|
|
drawWave() {
|
|
const canvas = document.getElementById('wave');
|
|
if(!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
canvas.width = canvas.clientWidth;
|
|
canvas.height = canvas.clientHeight;
|
|
|
|
if(!this.buffer) return;
|
|
|
|
const d = this.buffer.getChannelData(0);
|
|
const step = Math.ceil(d.length / canvas.width);
|
|
|
|
ctx.fillStyle = 'rgba(0, 243, 255, 0.6)';
|
|
ctx.strokeStyle = 'rgba(0, 243, 255, 0.9)';
|
|
ctx.lineWidth = 1;
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Draw waveform
|
|
ctx.beginPath();
|
|
for(let i = 0; i < canvas.width; i++) {
|
|
let min = 0, max = 0;
|
|
for(let j = 0; j < step; j++) {
|
|
const val = d[i * step + j] || 0;
|
|
min = Math.min(min, val);
|
|
max = Math.max(max, val);
|
|
}
|
|
|
|
const y1 = ((1 - max) * canvas.height / 2);
|
|
const y2 = ((1 - min) * canvas.height / 2);
|
|
|
|
ctx.fillRect(i, y1, 1, y2 - y1);
|
|
}
|
|
|
|
// Draw center line
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, canvas.height / 2);
|
|
ctx.lineTo(canvas.width, canvas.height / 2);
|
|
ctx.stroke();
|
|
},
|
|
|
|
loopVis() {
|
|
requestAnimationFrame(() => this.loopVis());
|
|
|
|
if(!this.nodes.analyser) return;
|
|
|
|
const arr = new Uint8Array(this.nodes.analyser.frequencyBinCount);
|
|
this.nodes.analyser.getByteFrequencyData(arr);
|
|
|
|
let sum = 0;
|
|
for(let v of arr) sum += v;
|
|
|
|
this.vuMeter = Math.min(100, (sum / arr.length) * 1.5);
|
|
}
|
|
}))
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|