summaryrefslogtreecommitdiff
path: root/sample_editor
diff options
context:
space:
mode:
authorAlex Evans <715855+mmalex@users.noreply.github.com>2024-11-10 13:13:46 +0000
committerAlex Evans <715855+mmalex@users.noreply.github.com>2024-11-10 13:13:46 +0000
commit4028a032e53e6c01a7ad273492567a57f5ab7b4f (patch)
treed85654d2842a80c6c79627f77f0e023dffc5ee74 /sample_editor
parent3a86555a7211f15fa1cb55de3cc898a761a188fd (diff)
downloadplinky-4028a032e53e6c01a7ad273492567a57f5ab7b4f.tar.gz
sample editor; this is before backtracking on contiguous mode
Diffstat (limited to 'sample_editor')
-rw-r--r--sample_editor/processor.js88
-rw-r--r--sample_editor/sample_editor.css188
-rw-r--r--sample_editor/sample_editor.html192
-rw-r--r--sample_editor/sample_editor2.html290
-rw-r--r--sample_editor/slicer.js1469
-rw-r--r--sample_editor/topbar.css93
6 files changed, 2320 insertions, 0 deletions
diff --git a/sample_editor/processor.js b/sample_editor/processor.js
new file mode 100644
index 0000000..67d77ba
--- /dev/null
+++ b/sample_editor/processor.js
@@ -0,0 +1,88 @@
+class CustomProcessor extends AudioWorkletProcessor {
+ static get parameterDescriptors() {
+ return [
+ { name: 'gain', defaultValue: 1.0, minValue: 0.0, maxValue: 2000.0 },
+ // Add parameters for the 8 biquad filters here
+ ...Array.from({ length: 8 }, (_, index) => [
+ { name: `b0_${index}`, defaultValue: 1.0 },
+ { name: `b1_${index}`, defaultValue: 0.0 },
+ { name: `b2_${index}`, defaultValue: 0.0 },
+ { name: `a1_${index}`, defaultValue: 0.0 },
+ { name: `a2_${index}`, defaultValue: 0.0 }
+ ]).flat()
+ ];
+ }
+
+ constructor() {
+ super();
+ this.sampleRate = sampleRate; // `sampleRate` is available globally here
+ console.log('Sample rate:', this.sampleRate);
+ // Set up state variables for the 8 biquad filters, initialize to 0
+ this.biquadState = Array.from({ length: 8 }, () => ({ state1: 0.0, state2: 0.0 }));
+ this.volumeEnvelope = [];
+ this.wavetablemode = false;
+ this.envelopeIndex = 0;
+ this.currentSample = 0;
+ this.port.onmessage = (event) => {
+ if (event.data.type === 'volumeEnvelope') {
+ this.volumeEnvelope = event.data.envelope;
+ this.dx = event.data.dx;
+ this.envelopeIndex = 0;
+ this.currentSample = 0;
+ this.wavetablemode = event.data.wavetablemode;
+
+ }
+ };
+ }
+
+ process(inputs, outputs, parameters) {
+ const input = inputs[0];
+ const output = outputs[0];
+ const gainParam = parameters.gain;
+ const pregain = gainParam[0];
+ for (let channel = 0; channel < input.length; channel++) {
+ const inputChannel = input[channel];
+ const outputChannel = output[channel];
+ let cs = this.currentSample;
+ let dx = this.dx;
+ for (let i = 0; i < inputChannel.length; i++, cs++) {
+ let currentSample = inputChannel[i];
+
+ // Run through 8 biquad filters in series
+ if (1) for (let biquadIndex = 0; biquadIndex < 8; biquadIndex++) {
+ const b0 = parameters[`b0_${biquadIndex}`][0];
+ const b1 = parameters[`b1_${biquadIndex}`][0];
+ const b2 = parameters[`b2_${biquadIndex}`][0];
+ const a1 = parameters[`a1_${biquadIndex}`][0];
+ const a2 = parameters[`a2_${biquadIndex}`][0];
+ const state = this.biquadState[biquadIndex];
+ // Apply Direct Form II Transposed structure
+ const output = currentSample * b0 + state.state1;
+ state.state1 = currentSample * b1 - a1 * output + state.state2;
+ state.state2 = currentSample * b2 - a2 * output;
+ currentSample = output;
+ }
+ let postgain = 1.0;
+ if (this.volumeEnvelope.length > 0) {
+ let x = cs * dx;
+ if (this.wavetablemode) x=x%1;
+ while (this.envelopeIndex < this.volumeEnvelope.length - 2 && x >= this.volumeEnvelope[this.envelopeIndex + 1].x)
+ this.envelopeIndex++;
+ while (this.envelopeIndex > 0 && x < this.volumeEnvelope[this.envelopeIndex].x)
+ this.envelopeIndex--;
+ const p1 = this.volumeEnvelope[this.envelopeIndex];
+ const p2 = this.volumeEnvelope[this.envelopeIndex + 1];
+ const t = Math.min(1, (x - p1.x) / (p2.x - p1.x));
+ postgain = p1.y + (p2.y - p1.y) * Math.pow(t, p1.power);
+ //if (cs < 16) postgain *= cs / 16;
+ }
+ outputChannel[i] = /*Math.tanh*/(currentSample * pregain) * postgain;
+ }
+ }
+ if (input.length > 0)
+ this.currentSample += input[0].length;
+ return true;
+ }
+}
+
+registerProcessor('custom-processor', CustomProcessor);
diff --git a/sample_editor/sample_editor.css b/sample_editor/sample_editor.css
new file mode 100644
index 0000000..f1f83c8
--- /dev/null
+++ b/sample_editor/sample_editor.css
@@ -0,0 +1,188 @@
+input[type="text"] {
+ font-family: 'Barlow Condensed', sans-serif !important;
+}
+
+input[type="range"]::-webkit-slider-thumb,
+input[type="checkbox"]:checked,
+input[type="radio"]:checked {
+ accent-color: hsl(var(--highlight-hue), 100%, 50%);
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ background: hsl(var(--highlight-hue), 100%, 50%);
+}
+
+input[type="range"]::-ms-thumb {
+ background: hsl(var(--highlight-hue), 100%, 50%);
+}
+
+input[type="range"] {
+ accent-color: hsl(var(--highlight-hue), 100%, 50%);
+}
+
+.canvas-container {
+ width: 100%;
+ height: 256px;
+ border-bottom: 1px solid #ccc;
+ position: relative;
+}
+
+#canvas {
+ width: 100%;
+ height: 100%;
+}
+
+.main-container {
+ display: flex;
+ height: 256px;
+ border-bottom: 1px solid #ccc;
+}
+
+.slice-list {
+ width: 25%;
+ padding: 5px;
+ box-sizing: border-box;
+ border-right: 1px solid #ccc;
+ overflow: auto;
+}
+
+.slice-list ul {
+ list-style: none;
+ padding: 0px;
+ margin: 0px;
+}
+
+.slice-list li {
+ padding: 5px;
+ cursor: pointer;
+ border-bottom: 1px solid #ccc;
+ display: flex;
+ align-items: center;
+}
+
+.slice-list li:hover {
+ background-color: #666;
+ color: white !important;
+}
+
+.slice-list .selected {
+ background-color: hsl(var(--highlight-hue), 70%, 50%);
+ color: white;
+}
+
+.color-chip {
+ width: 10px;
+ height: 10px;
+ margin-right: 10px;
+ display: inline-block;
+}
+
+.editor-container {
+ width: 33%;
+ padding: 5px;
+ box-sizing: border-box;
+ border-right: 1px solid #ccc;
+ display: none;
+}
+
+.name-track-container {
+ display: grid;
+ grid-template-columns: 65% 30%;
+ gap: 10px;
+ align-items: center;
+}
+
+.editor-container label {
+ display: block;
+}
+
+div .spacer {
+ height: 10px;
+}
+
+.slider {
+ width: 100%;
+}
+
+.radio-group {
+ margin-top: 10px;
+ display: flex;
+ gap: 10px;
+}
+
+.radio-group label {
+ margin-right: 10px;
+}
+
+.eq-container {
+ width: 42%;
+ height: 257px;
+ padding: 0px;
+ box-sizing: border-box;
+ border-bottom: 1px solid #ccc;
+ position: relative;
+}
+
+#eqCanvas,
+#fftCanvas {
+ width: 100%;
+ height: 256px;
+ top: 0;
+ left: 0;
+ position: absolute;
+}
+
+#fftCanvas {
+ pointer-events: none;
+ z-index: 1;
+}
+
+#eqCanvas {
+ z-index: 0;
+}
+
+#audioCanvas {
+ width: 100%;
+ height: 256px;
+}
+
+.header {
+ width: 100%;
+ height: 24px;
+ border-bottom: 1px solid #ccc;
+ background-color: white;
+ color: hsl(var(--highlight-hue), 70%, 50%);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 10px;
+ box-sizing: border-box;
+ font-size: 18px;
+}
+
+.title {
+ pointer-events: none;
+}
+
+#help {
+ display: block;
+ position: absolute;
+ text-align: center;
+ padding: 10px;
+ font-size: 48px;
+ color: #888;
+}
+
+.header-button {
+ background-color: hsl(var(--highlight-hue), 70%, 50%);
+ color: white;
+ border: none;
+ padding: 2px 8px;
+ cursor: pointer;
+ font-size: 12px;
+}
+
+.header-button:hover {
+ background-color: #666;
+ color: white;
+}
diff --git a/sample_editor/sample_editor.html b/sample_editor/sample_editor.html
new file mode 100644
index 0000000..7322305
--- /dev/null
+++ b/sample_editor/sample_editor.html
@@ -0,0 +1,192 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>WebUSB App</title>
+</head>
+<body>
+<button id="connect">Connect...</button>
+<button id="send">Send</button>
+<br/>
+<canvas id="canvas" width="2048" height="256"></canvas>
+<script>
+let device;
+let interfaceNumber;
+let endpointIn;
+let endpointOut;
+let state = 0, cmd = 0, idx = 0, offset = 0, len = 0, big = 0, payload = [];
+
+function drawWaveform(bytes) {
+ const canvas = document.getElementById('canvas');
+ const ctx = canvas.getContext('2d');
+ const width = canvas.width = 2048; // ensure canvas is 2048 pixels wide
+ const height = canvas.height;
+
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height);
+
+ // Interpret bytes as 16-bit little-endian signed integers
+ const values = [];
+ for (let i = 0; i < bytes.length; i += 2) {
+ const value = bytes[i] | (bytes[i + 1] << 8); // Little-endian conversion
+ values.push(value < 0x8000 ? value : value - 0x10000); // Interpret as signed
+ }
+
+ // Scale and draw waveform
+ ctx.beginPath();
+ for (let x = 0; x < values.length && x < width; x++) {
+ const y = ((-values[x] + 32768) / 65535) * height; // Map y range to canvas height
+ x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
+ }
+ ctx.stroke();
+}
+
+
+async function connectDevice() {
+ try {
+ device = await navigator.usb.requestDevice({ filters: [{ vendorId: 0xcafe }] });
+ await device.open();
+ if (device.configuration === null) await device.selectConfiguration(1);
+
+ const interfaces = device.configuration.interfaces;
+ for (const element of interfaces) {
+ for (const alternate of element.alternates) {
+ if (alternate.interfaceClass === 0xFF) { // Vendor-specific interface class
+ interfaceNumber = element.interfaceNumber;
+ for (const endpoint of alternate.endpoints) {
+ if (endpoint.direction === 'in') {
+ endpointIn = endpoint.endpointNumber;
+ }
+ if (endpoint.direction === 'out') {
+ endpointOut = endpoint.endpointNumber;
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (endpointIn === undefined || endpointOut === undefined) {
+ throw new Error('Endpoints not found');
+ }
+
+ await device.claimInterface(interfaceNumber);
+ await device.selectAlternateInterface(interfaceNumber, 0);
+ reset(0); // Reset state machine
+ console.log("Device connected:", device, endpointIn, endpointOut);
+
+ // Change button to "Disconnect"
+ document.getElementById('connect').textContent = 'Disconnect';
+
+ // Send initial message and start reading loop
+ readLoop();
+ } catch (error) {
+ console.error("Connection error:", error);
+ }
+}
+
+async function disconnectDevice() {
+ try {
+ if (device) {
+ await device.close();
+ device = null;
+ console.log("Device disconnected.");
+ }
+ reset(0);
+ document.getElementById('connect').textContent = 'Connect...';
+ } catch (error) {
+ console.error("Disconnection error:", error);
+ }
+}
+
+async function sendMsg(cmd, idx, offset, len, optionalData = []) {
+ const big = (offset>=65536 || len>=65536);
+ const header = big ?
+ [0xf3, 0x0f, 0xab, 0xcb, cmd, idx, offset & 255, (offset >> 8) & 255, (offset >> 16) & 255, offset >> 24, len & 255, (len >> 8) & 255, (len >> 16) & 255, len >> 24] :
+ [0xf3, 0x0f, 0xab, 0xca, cmd, idx, offset & 255, offset >> 8, len & 255, len >> 8];
+ const message = header.concat(optionalData);
+ for (let i = 0; i < message.length; i += 64) {
+ const chunk = message.slice(i, i + 64);
+ console.log("Sending data:", chunk);
+ await device.transferOut(endpointOut, new Uint8Array(chunk));
+ }
+}
+
+async function readLoop() {
+ while (device) {
+ try {
+ const result = await device.transferIn(endpointIn, 64);
+ const data = new Uint8Array(result.data.buffer);
+ console.log("Received data:", data);
+ data.forEach(byte => processByte(byte));
+ console.log("State:", state, big, cmd, idx, offset, len, payload.length);
+ } catch (error) {
+ console.error("Read error, device disconnected:", error);
+ disconnectDevice(); // Clean up on error
+ break;
+ }
+ }
+}
+
+function reset(x) {
+ state = big = cmd = idx = offset = len = 0;
+ payload = [];
+ if (x === 0xf3) state = 1;
+ console.log("State reset. Starting state:", state);
+}
+
+function processByte(x) {
+ switch (state) {
+ case 0: if (x === 0xf3) state++; else reset(x); break;
+ case 1: if (x === 0x0f) state++; else reset(x); break;
+ case 2: if (x === 0xab) state++; else reset(x); break;
+ case 3: big = (x=== 0xcb) ? 1 : 0; if (x === 0xca || x==0xcb) state++; else reset(x); break;
+ case 4: cmd = x; state++; break;
+ case 5: idx = x; state++; break;
+ case 6: offset = x; state++; break;
+ case 7: offset += x * 256; state=big ? 8 : 10; break;
+ case 8: offset += x * 65536; state++; break;
+ case 9: offset += x * 16777216; state++; break;
+ case 10: len = x; state++; break;
+ case 11: len += x * 256;
+ if (big) state++; else {
+ state=14;
+ if (len < 0) reset(x);
+ else if (len === 0) processPacket();
+ }
+ break;
+ case 12: len += x * 65536; state++; break;
+ case 13: len += x * 16777216; state++;
+ if (len < 0) reset(x);
+ else if (len === 0) processPacket();
+ break;
+ case 14: payload.push(x); len--;
+ if (len === 0) processPacket();
+ break;
+ }
+}
+
+function processPacket() {
+ console.log("Packet received:", { cmd, idx, offset, len, payload });
+ drawWaveform(payload);
+ reset();
+}
+
+document.getElementById('connect').addEventListener('click', async () => {
+ if (device) {
+ await disconnectDevice();
+ } else {
+ await connectDevice();
+ }
+});
+
+document.getElementById('send').addEventListener('click', async () => {
+ if (device) {
+ idx=5;
+ await sendMsg(4, 0, 1031*2*idx, 1031*2);
+ }
+});
+</script>
+</body>
+</html>
diff --git a/sample_editor/sample_editor2.html b/sample_editor/sample_editor2.html
new file mode 100644
index 0000000..5671603
--- /dev/null
+++ b/sample_editor/sample_editor2.html
@@ -0,0 +1,290 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Tabbed Interface App</title>
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
+ <link rel="stylesheet" href="topbar.css">
+ <link rel="stylesheet" href="sample_editor.css">
+ <style>
+ </style>
+</head>
+<body>
+ <div class="top-menu-bar">
+ <div class="tab burger-menu" id="burger-menu" onclick="toggleDropdown()">
+ <i class="fas fa-bars burger-menu-icon"></i>
+ </div>
+ <div class="dropdown-menu" id="dropdown-menu">
+ <div class="dropdown-item" onclick="menuAction('load')">Load From Device</div>
+ <div class="dropdown-item" onclick="menuAction('save')">Save to Device</div>
+ <div class="dropdown-item" onclick="menuAction('save_uf2')">Save to UF2</div>
+ <div class="dropdown-item" onclick="menuAction('clear')">Clear</div>
+ </div>
+ <div class="tab" onclick="selectTab(0)">
+ <div class="circle">1</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(1)">
+ <div class="circle">2</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(2)">
+ <div class="circle">3</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(3)">
+ <div class="circle">4</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(4)">
+ <div class="circle">5</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(5)">
+ <div class="circle">6</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(6)">
+ <div class="circle">7</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(7)">
+ <div class="circle">8</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ <div class="tab" onclick="selectTab(8)">
+ <div class="circle">W</div>
+ <canvas class="minicanvas-container"></canvas>
+ </div>
+ </div>
+ <!----------------------------------------->
+ <div class="canvas-container">
+ <span id="help">Drop one or more audio files here to begin.</span>
+ <canvas id="audioCanvas"></canvas>
+ </div>
+ <div class="main-container">
+ <div class="slice-list">
+ <ul id="sliceList"></ul>
+ </div>
+ <div class="editor-container">
+ <div class="name-track-container">
+ <div>
+ <label for="sliceName">Name:</label>
+ <input type="text" id="sliceName" style="width: 100%;">
+ </div>
+ </div>
+ <div class="spacer"></div>
+ <label for="gainSlider">Pre-Gain: <span id="gainValue">0</span> dB</label>
+ <input type="range" id="gainSlider" class="slider" min="-60" max="60" value="0">
+
+ <div class="radio-group">
+ <!-- <button id ="normalize">Normalize</button> -->
+ <label><input type="checkbox" id="pitchedCheckbox"> Pitched</label>
+ <label><input type="checkbox" id="reversedCheckbox"> Reversed</label>
+ </div>
+ <div class="spacer"></div>
+
+ <label for="basePitchSlider" id="basePitchLabel" style="display: none;">Base Pitch: <span
+ id="basePitchValue">C4</span></label>
+ <input type="range" id="basePitchSlider" class="slider" min="0" max="127" value="48" style="display: none;">
+
+ </div>
+ <div class="eq-container">
+ <canvas id="eqCanvas"></canvas>
+ <canvas id="fftCanvas"></canvas>
+ </div>
+ </div>
+ <script src="slicer.js" defer> </script>
+
+ <script>
+
+
+ function toggleDropdown() {
+ const dropdown = document.getElementById('dropdown-menu');
+ dropdown.classList.toggle('open');
+ }
+
+ function menuAction(action) {
+ console.log('Menu action:', action);
+ toggleDropdown();
+ }
+
+ function renderMiniCanvas(index, minimap) {
+ const canvas = document.querySelectorAll('.minicanvas-container')[index];
+ canvas.width=256;
+ canvas.height=32;
+ const ctx = canvas.getContext('2d');
+ ctx.fillStyle = 'black';
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ for (let i = 0; i < canvas.width; i++) {
+ const y = minimap[i*8];
+ ctx.fillRect(i, 16 - y, 1, y * 2);
+ }
+ }
+
+ // Close dropdown when clicking outside of it
+ document.addEventListener('click', (event) => {
+ const dropdown = document.getElementById('dropdown-menu');
+ const burgerMenu = document.getElementById('burger-menu');
+ if (!dropdown.contains(event.target) && !burgerMenu.contains(event.target)) {
+ dropdown.classList.remove('open');
+ }
+ });
+
+ function init_topbar() {
+ selectTab(0);
+
+ }
+
+ document.addEventListener("DOMContentLoaded", (event) => {
+ console.log("DOM fully loaded and parsed");
+ function resizeCanvas() {
+ const eqcanvas = document.getElementById('eqCanvas');
+ eqcanvas.width = eqcanvas.clientWidth * dpr;
+ eqcanvas.height = eqcanvas.clientHeight * dpr;
+
+ const fftcanvas = document.getElementById('fftCanvas');
+ fftcanvas.width = fftcanvas.clientWidth * dpr;
+ fftcanvas.height = fftcanvas.clientHeight * dpr;
+
+ const wfcanvas = document.getElementById('audioCanvas');
+ wfcanvas.width = wfcanvas.clientWidth * dpr;
+ wfcanvas.height = wfcanvas.clientHeight * dpr;
+ updateAudioDisplay();
+ }
+
+
+ window.addEventListener('resize', resizeCanvas);
+
+ function onDragStart(event, index) {
+ event.dataTransfer.setData('text/plain', index);
+ }
+ function onDrop(event, dropIndex) {
+ const draggedIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
+ if (draggedIndex !== dropIndex) {
+ const draggedItem = G.slices[draggedIndex];
+ G.slices.splice(draggedIndex, 1);
+ G.slices.splice(dropIndex, 0, draggedItem);
+ updateSliceList();
+ selectSlice(dropIndex);
+ }
+ }
+
+ document.getElementById('sliceName').addEventListener('input', function () {
+ if (G.currentSlice >= G.slices.length) return;
+ G.slices[G.currentSlice].name = this.value;
+ document.querySelector(`#sliceList li:nth-child(${G.currentSlice + 1})`).innerHTML = `<span class="color-chip" style="background-color: hsl(${G.slices[G.currentSlice].hue}, 100%, 50%)"></span>${this.value}`;
+ });
+
+ document.getElementById('gainSlider').addEventListener('input', function () {
+ if (G.currentSlice >= G.slices.length) return;
+ G.slices[G.currentSlice].gain = this.value;
+ document.getElementById('gainValue').innerText = this.value;
+ if (customNode) {
+ const gainValue = db2lin(G.slices[G.currentSlice].gain);
+ customNode.parameters.get('gain').setValueAtTime(gainValue, 0);
+ }
+ updateAudioDisplay();
+ });
+
+
+ document.getElementById('basePitchSlider').addEventListener('input', function () {
+ if (G.currentSlice >= G.slices.length) return;
+ document.getElementById('basePitchValue').innerText = midiToNoteName(this.value);
+ G.slices[G.currentSlice].base_pitch = parseInt(this.value);
+ updateAudioDisplay();
+ });
+
+ document.getElementById('reversedCheckbox').addEventListener('change', function () {
+ if (G.currentSlice >= G.slices.length) return;
+ G.slices[G.currentSlice].reversed = this.checked;
+ updateAudioDisplay();
+ });
+
+ document.getElementById('pitchedCheckbox').addEventListener('change', function () {
+ G.pitched = this.checked;
+ updateAudioDisplay();
+ updateExtraSlider();
+ });
+
+ function update_value(event) {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ this.blur();
+ const value = parseFloat(this.innerText);
+ if (!isNaN(value)) {
+ updateAudioDisplay();
+ }
+ }
+ }
+ document.addEventListener('keyup', function (event) {
+ if (event.key == 'Shift') {
+ console.log("end megazoom");
+ if (prev_zoom) {
+ G.zoom = prev_zoom;
+ G.first_sample = prev_first_sample;
+ prev_zoom = null;
+ updateAudioDisplay();
+ }
+ }
+ });
+
+ let prev_first_sample = null;
+ let prev_zoom = null;
+
+ document.addEventListener('keydown', function (event) {
+ makeAudioContext();
+ // if an input control is focused, then don't process keyboard shortcuts
+ if (document.activeElement.id == 'sampleRateValue' || document.activeElement.id == 'bpmValue' || document.activeElement.id == 'sliceName') return;
+ console.log(event.key);
+ if (event.key == 'Escape') {
+ selectSlice(G.slices.length);
+ return;
+ }
+ else if (event.key == 'Shift') {
+ if (!prev_zoom) {
+ console.log("megazoom");
+ prev_zoom = G.zoom;
+ prev_first_sample = G.first_sample;
+ if (G.zoom > 8)
+ G.zoom = 4;
+ else
+ G.zoom = 1;
+ G.first_sample = mouseSample - mouseX * G.zoom;
+ G.first_sample = Math.max(0, Math.min(G.first_sample, G.the_audio.length - wfcanvas.width * G.zoom));
+ updateAudioDisplay();
+ }
+ }
+ if (event.key === 'Backspace') {
+ deleteSlice(G.currentSlice, event.ctrlKey || event.metaKey || event.shiftKey);
+ }
+ else if (event.key === 'ArrowUp' || event.key === 'ArrowDown' || event.key == 'ArrowLeft' || event.key == 'ArrowRight') {
+ event.preventDefault();
+ if (event.key == 'ArrowUp' || event.key == 'ArrowLeft')
+ if (G.currentSlice > 0) G.currentSlice--;
+ else G.currentSlice = G.slices.length - 1;
+ else
+ if (G.currentSlice < G.slices.length - 1) G.currentSlice++;
+ else G.currentSlice = 0;
+ selectSlice(G.currentSlice);
+ }
+ else if (event.key.toLowerCase() == 'r') {
+ G.slices[G.currentSlice].reversed = !G.slices[G.currentSlice].reversed;
+ document.getElementById('reversedCheckbox').checked = G.slices[G.currentSlice].reversed;
+ updateAudioDisplay();
+ } else if (event.key == ' ') {
+ playAudio();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ });
+ resizeCanvas();
+ updateSliceList();
+ setupBiquads();
+ drawEQCurve();
+ init_topbar();
+ });
+ </script>
+</body>
+</html>
diff --git a/sample_editor/slicer.js b/sample_editor/slicer.js
new file mode 100644
index 0000000..d52d54e
--- /dev/null
+++ b/sample_editor/slicer.js
@@ -0,0 +1,1469 @@
+const dpr = window.devicePixelRatio || 1;
+
+
+let customNode = null;
+const eqcanvas = document.getElementById('eqCanvas');
+const eqctx = eqcanvas.getContext('2d');
+const sampleRate = 32000; // Sample rate
+const TAU = 2 * Math.PI;
+const butterq1 = 0.54119610;
+const butterq2 = 1.3065630;
+let audioContext;
+let audioSource = null;
+let analyserNode = null;
+let isPlaying = false;
+let brightnessArray;
+let imageData;
+let frequencyData;
+let frequencyDatapeak;
+let frequencyDatahold;
+
+const biquad_params = [
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+ { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 },
+];
+const default_eq = [
+ { type: 'highpass', freq: 40, gain: 0, q: butterq1 },
+ { type: 'lowshelf', freq: 200, gain: 0, q: 1 },
+ { type: 'peaking', freq: 500, gain: 0, q: 0.5 },
+ { type: 'peaking', freq: 2000, gain: 0, q: 0.5 },
+ { type: 'highshelf', freq: 6000, gain: 0, q: 1 },
+ { type: 'lowpass', freq: 14000, gain: 0, q: butterq1 },
+ { type: 'highpass', freq: 40, gain: 0, q: butterq2 },
+ { type: 'lowpass', freq: 14000, gain: 0, q: butterq2 }
+];
+const default_volenv = [
+ { x: 0.0, y: 1.0, power: 10000 },
+ { x: 1.0, y: 0.0, power: 1 }
+];
+const default_volenv_wt = [
+ { x: 0.0, y: 1.0, power: 1 },
+ { x: 1.0, y: 1.0, power: 1 }
+];
+
+
+const wfcanvas = document.getElementById('audioCanvas');
+const wfctx = wfcanvas.getContext('2d');
+const fftcanvas = document.getElementById('fftCanvas');
+const fftctx = fftcanvas.getContext('2d');
+const playButton = document.getElementById('playButton');
+
+function db2lin(db) {
+ return Math.pow(10, db / 20);
+}
+
+function midiToFreq(midiValue) {
+ return 440 * Math.pow(2, (midiValue - 69) / 12);
+}
+
+function freqToMidi(freq) {
+ return (69 + 12 * Math.log2(freq / 440));
+}
+
+function midiToNoteName(midiValue) {
+ midiValue = Math.round(midiValue);
+ const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
+ const octave = Math.floor(midiValue / 12) - 1;
+ const note = notes[midiValue % 12];
+ return `${note}${octave}`;
+}
+
+const sliceListElement = document.getElementById('sliceList');
+
+function make_state(wt, cs) {
+ return {
+ currentSlice: 0,
+ slices: JSON.parse("[]"),
+ filename_list: JSON.parse("[]"),
+ the_audio : new Float32Array(),
+ first_sample : 0,
+ zoom : 64,
+ pitched : false,
+ wavetablemode : wt,
+ contiguous_slices : cs,
+ };
+}
+const tab_states = [];
+let selectedTab = 0;
+
+for (let i = 0; i < 9; i++) {
+ tab_states.push(make_state(i==8, i<8));
+}
+let G = tab_states[selectedTab];
+function selectTab(index) {
+ //if (index === selectedTab) return;
+ console.log('---------- SELECT TAB', index);
+ G = tab_states[index];
+ const tabs = document.querySelectorAll('.tab');
+ tabs[selectedTab + 1].classList.remove('selected'); // offset for burger menu
+ tabs[selectedTab + 1].querySelector('canvas').style.opacity = 0.25;
+ selectedTab = index;
+ tabs[selectedTab + 1].querySelector('canvas').style.opacity = 1;
+ tabs[selectedTab + 1].classList.add('selected');
+ updateSliceList();
+ updateAudioDisplay();
+
+
+}
+
+
+function get_default_volenv() {
+ return G.wavetablemode ? default_volenv_wt : default_volenv;
+}
+
+const default_slice = {
+ name: "noname",
+ start: 0,
+ end: 1,
+ gain: 0,
+ eq: JSON.parse(JSON.stringify(default_eq)),
+ volenv: JSON.parse(JSON.stringify(get_default_volenv())),
+ reversed: false,
+ base_pitch: 48,
+ };
+
+function enforceSliceConstraints( delete_empty_slices=false) {
+ let deleted = false;
+ if (!G.contiguous_slices) return false;
+ while (G.slices.length > 8) {
+ // find the shortest slice and delete it
+ let shortest = 0;
+ for (let i = 1; i < G.slices.length; i++) {
+ if (G.slices[i].end - G.slices[i].start < G.slices[shortest].end - G.slices[shortest].start)
+ shortest = i;
+ }
+ if (shortest < G.currentSlice) G.currentSlice--;
+ G.slices.splice(shortest, 1);
+ deleted=true;
+ }
+ if (G.currentSlice < G.slices.length-1) {
+ if (G.slices[G.currentSlice].end > G.slices[G.currentSlice+1].end)
+ G.slices[G.currentSlice].end = G.slices[G.currentSlice+1].end;
+ G.slices[G.currentSlice+1].start = G.slices[G.currentSlice].end;
+ }
+ const start = G.slices.length > 0 ? G.slices[0].start : 0;
+ let maxlen = Math.min(G.the_audio.length, 2*1024*1024 + start);
+ for (let i = 0; i<G.slices.length; i++) {
+ if (G.slices[i].start < 0) G.slices[i].start = 0;
+ if (G.slices[i].end > maxlen) G.slices[i].end = maxlen;
+ if (i<G.slices.length-1) G.slices[i].end = G.slices[i+1].start;
+ if (i>0 && G.slices.start < G.slices[i-1].end) G.slices[i].start = G.slices[i-1].end;
+ if (G.slices[i].start >= G.slices[i].end) {
+ G.slices[i].start=G.slices[i].end;
+ }
+ }
+ if (delete_empty_slices) {
+ for (let i = G.slices.length-1; i>=0; ) {
+ if (G.slices[i].start >= G.slices[i].end) {
+ G.slices.splice(i, 1);
+ deleted=true;
+ if (G.currentSlice > i) G.currentSlice--;
+ } else {
+ i--;
+ }
+ }
+ }
+ if (deleted)
+ updateSliceList();
+ return deleted;
+}
+
+function updateSliceList() {
+ sliceListElement.innerHTML = '';
+ enforceSliceConstraints();
+ if (G.currentSlice >= G.slices.length) G.currentSlice = 0;
+ G.slices.forEach((slice, index) => {
+ console.log(slice);
+ const listItem = document.createElement('li');
+ listItem.innerHTML = `<span class="color-chip" style="background-color: hsl(${slice.hue}, 100%, 50%)"></span>${slice.name}`;
+ listItem.onclick = () => selectSlice(index);
+ listItem.setAttribute('draggable', 'true');
+ listItem.ondragstart = (event) => onDragStart(event, index);
+ listItem.ondragover = (event) => event.preventDefault();
+ listItem.ondrop = (event) => onDrop(event, index);
+ sliceListElement.appendChild(listItem);
+ });
+ // Reselect the current slice after reordering
+ selectSlice(G.currentSlice);
+ if (G.slices.length > 0 || G.the_audio.length > 0) {
+ document.querySelectorAll('.editor-container').forEach(editor => editor.style.display = 'block');
+ document.getElementById('help').style.display = 'none';
+ } else {
+ document.querySelectorAll('.editor-container').forEach(editor => editor.style.display = 'none');
+ document.getElementById('help').style.display = 'block';
+ }
+}
+
+
+function deleteSlice(index, deletewav) {
+ if (index >= G.slices.length) return;
+ const start = G.slices[index].start;
+ const end = G.slices[index].end;
+ G.slices.splice(index, 1);
+ if (deletewav) {
+ let new_audio = new Float32Array(G.the_audio.length - (end - start));
+ new_audio.set(G.the_audio.slice(0, start));
+ new_audio.set(G.the_audio.slice(end), start);
+ G.the_audio = new_audio;
+ function fix_pos(x) {
+ if (x < start) return x;
+ if (x >= end) return x - (end - start);
+ return start;
+ }
+ for (let i = G.filename_list.length - 1; i >= 0; i--) {
+ G.filename_list[i].start = fix_pos(G.filename_list[i].start);
+ G.filename_list[i].end = fix_pos(G.filename_list[i].end);
+ if (G.filename_list[i].start >= G.filename_list[i].end) {
+ G.filename_list.splice(i, 1);
+ }
+ }
+ for (let i = G.slices.length - 1; i >= 0; i--) {
+ G.slices[i].start = fix_pos(G.slices[i].start);
+ G.slices[i].end = fix_pos(G.slices[i].end);
+ if (G.slices[i].start >= G.slices[i].end) {
+ G.slices.splice(i, 1);
+ }
+ }
+ }
+ updateSliceList();
+}
+
+function ensureSliceVisible(start, end, forcezoom = false) {
+ console.log("ensureSliceVisible", start, end);
+ // lets move the slice to be within the display
+ const minzoom = Math.max(0.1, (end - start) / wfcanvas.width);
+ if (G.zoom < minzoom || forcezoom)
+ G.zoom = minzoom;
+ if (start < G.first_sample) {
+ G.first_sample = start;
+ }
+ const last_sample = G.first_sample + wfcanvas.width * G.zoom;
+ if (end > last_sample) {
+ G.first_sample += end - last_sample;
+ }
+}
+
+function selectSlice(index) {
+ G.currentSlice = index;
+ const slice = G.currentSlice < G.slices.length ? G.slices[G.currentSlice] : default_slice;
+ document.getElementById('sliceName').value = slice.name;
+ document.getElementById('gainSlider').value = slice.gain;
+ document.getElementById('gainValue').innerText = slice.gain;
+
+ document.getElementById('basePitchSlider').value = slice.base_pitch;
+ document.getElementById('basePitchValue').innerText = midiToNoteName(slice.base_pitch);
+
+ document.documentElement.style.setProperty('--highlight-hue', index * 45);
+ if (index < G.slices.length) {
+ document.querySelector(`#sliceList li:nth-child(${index + 1})`).scrollIntoView();
+ document.querySelectorAll('.slice-list li').forEach((li, liIndex) => {
+ li.classList.toggle('selected', liIndex === index);
+ li.style.color = liIndex === index ? 'white' : 'black';
+ });
+ }
+ updateExtraSlider();
+ document.getElementById('reversedCheckbox').checked = slice.reversed;
+ setupBiquads();
+ if (G.currentSlice < G.slices.length)
+ ensureSliceVisible(slice.start, slice.end);
+ updateAudioDisplay();
+}
+
+function updateExtraSlider() {
+ document.getElementById('basePitchSlider').style.display = 'none';
+ document.getElementById('basePitchLabel').style.display = 'none';
+ if (G.pitched) {
+ document.getElementById('basePitchSlider').style.display = 'block';
+ document.getElementById('basePitchLabel').style.display = 'block';
+ document.getElementById('basePitchValue').innerText = midiToNoteName(document.getElementById('basePitchSlider').value);
+ }
+}
+
+///////////////////////// EQ
+
+console.log("hello from eq", eqcanvas.width, eqcanvas.height);
+let nearest_filter = -1; // The nearest filter to the mouse (-1 means none)
+let is_dragging = false; // Whether we are currently dragging a filter
+
+function x2freq(x) {
+ return 16 * Math.pow(10, (x / eqcanvas.width) * 3); // log scale 20Hz to 20kHz
+}
+
+function freq2x(freq) {
+ return Math.log10(freq / 16) * (eqcanvas.width / 3);
+}
+
+function gain2y(gain) {
+ return (24 - gain) * (eqcanvas.height / 60);
+}
+
+function setupBiquad(filter, index) {
+ const freq = clamp((TAU / sampleRate) * filter.freq, (TAU / sampleRate) * 10, Math.PI - 0.05);
+ const sn = Math.sin(freq), cs = Math.cos(freq);
+ let alpha, A, b0, b1, b2, a0, a1, a2;
+
+ if (filter.type === 'highpass' || filter.type === 'lowpass') {
+ alpha = sn / (2 * filter.q);
+ if (filter.type === 'lowpass') {
+ b1 = 1 - cs;
+ b0 = b2 = b1 / 2;
+ a0 = 1 + alpha;
+ a1 = -2 * cs;
+ a2 = 1 - alpha;
+ } else if (filter.type === 'highpass') {
+ b1 = -(1 + cs);
+ b0 = b2 = -b1 / 2;
+ a0 = 1 + alpha;
+ a1 = -2 * cs;
+ a2 = 1 - alpha;
+ }
+ } else if (filter.type === 'peaking') {
+ A = Math.pow(10, filter.gain / 40);
+ alpha = sn * Math.sinh((0.69314718055994 * 0.5 * filter.q * freq) / sn);
+ b0 = 1 + alpha * A;
+ b1 = -2 * cs;
+ b2 = 1 - alpha * A;
+ a0 = 1 + alpha / A;
+ a1 = b1;
+ a2 = 1 - alpha / A;
+ } else if (filter.type === 'lowshelf' || filter.type === 'highshelf') {
+ A = Math.pow(10, filter.gain / 20);
+ alpha = sn * Math.sqrt((A + 1) * (1 / filter.q - 1) + 2 * A);
+ if (filter.type === 'lowshelf') {
+ b0 = A * ((A + 1) - (A - 1) * cs + alpha);
+ b1 = 2 * A * ((A - 1) - (A + 1) * cs);
+ b2 = A * ((A + 1) - (A - 1) * cs - alpha);
+ a0 = (A + 1) + (A - 1) * cs + alpha;
+ a1 = -2 * ((A - 1) + (A + 1) * cs);
+ a2 = (A + 1) + (A - 1) * cs - alpha;
+ } else if (filter.type === 'highshelf') {
+ b0 = A * ((A + 1) + (A - 1) * cs + alpha);
+ b1 = -2 * A * ((A - 1) + (A + 1) * cs);
+ b2 = A * ((A + 1) + (A - 1) * cs - alpha);
+ a0 = (A + 1) - (A - 1) * cs + alpha;
+ a1 = 2 * ((A - 1) - (A + 1) * cs);
+ a2 = (A + 1) - (A - 1) * cs - alpha;
+ }
+ }
+ const bqp = biquad_params[index];
+ bqp.b0 = b0 / a0;
+ bqp.b1 = b1 / a0;
+ bqp.b2 = b2 / a0;
+ bqp.a1 = a1 / a0;
+ bqp.a2 = a2 / a0;
+ if (customNode) {
+ customNode.parameters.get('b0_' + index).setValueAtTime(bqp.b0, 0);
+ customNode.parameters.get('b1_' + index).setValueAtTime(bqp.b1, 0);
+ customNode.parameters.get('b2_' + index).setValueAtTime(bqp.b2, 0);
+ customNode.parameters.get('a1_' + index).setValueAtTime(bqp.a1, 0);
+ customNode.parameters.get('a2_' + index).setValueAtTime(bqp.a2, 0);
+ }
+}
+
+function setupBiquads() {
+ if (G.currentSlice >= G.slices.length) return;
+ slice = G.slices[G.currentSlice];
+ for (let i = 0; i < 8; ++i) {
+ setupBiquad(slice.eq[i], i);
+ }
+}
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function GainDB(freq, filter) {
+ const omega = freq * TAU / (2.0 * sampleRate);
+ const s2 = Math.sin(omega);
+ const phi = 4.0 * s2 * s2;
+ const As = 1.0 + filter.a1 + filter.a2;
+ const Bs = filter.b0 + filter.b1 + filter.b2;
+ const num = Bs * Bs + (filter.b0 * filter.b2 * phi - (filter.b1 * (filter.b0 + filter.b2) + 4.0 * filter.b0 * filter.b2)) * phi;
+ const den = As * As + (filter.a2 * phi - (filter.a1 * (1.0 + filter.a2) + 4.0 * filter.a2)) * phi;
+ const magSquared = num / den;
+ return 10 * Math.log10(magSquared);
+}
+
+function drawEQCurve() {
+ eqctx.clearRect(0, 0, eqcanvas.width, eqcanvas.height);
+ if (G.currentSlice >= G.slices.length) return;
+ const slice = G.slices[G.currentSlice];
+ const accent_col = `hsl(${G.currentSlice * 45}, 70%, 50%)`;
+ const inv_accent_col = `hsl(${G.currentSlice * 45}, 70%, 50%)`;
+ const inv_accent_col_fill = `hsl(${G.currentSlice * 45}, 70%, 50%, 0.2)`;
+ eqctx.font = (10 * dpr) + 'px Arial';
+ eqctx.fillStyle = 'lightgray';
+ eqctx.lineWidth = 1;
+ for (gain = -24; gain < 24; gain += 6) {
+ eqctx.strokeStyle = (gain == 0) ? 'black' : ((gain / 6) & 1) ? 'lightgray' : 'gray';
+ let y = gain2y(gain);
+ drawHorizontalLine(y);
+ if (!((gain / 6) & 1)) eqctx.fillText(gain, 2, y - 2);
+ }
+ // Frequency markers
+ for (let freq = 10; freq <= 20000; freq *= 10) {
+ let x1 = freq2x(freq);
+ let x5 = freq2x(freq * 5);
+ eqctx.fillText(freq >= 1000 ? freq / 1000 + 'k' : freq, x1 + 2, eqcanvas.height - 2);
+ eqctx.fillText(freq >= 200 ? freq / 200 + 'k' : freq * 5, x5 + 2, eqcanvas.height - 2);
+ eqctx.strokeStyle = 'gray';
+ drawVerticalLine(x1);
+ drawVerticalLine(x5);
+ eqctx.strokeStyle = 'lightgray';
+ for (let mul = 2; mul < 10; mul++)
+ if (mul != 5) drawVerticalLine(freq2x(freq * mul));
+ }
+
+ // Draw EQ curve
+ eqctx.beginPath();
+ for (let x = 0; x <= eqcanvas.width; x++) {
+ const freq = x2freq(x);
+ let totalGain = 0;
+ for (const filter of biquad_params) {
+ totalGain += GainDB(freq, filter);
+ }
+ const y = gain2y(totalGain);
+ if (x === 0) {
+ eqctx.moveTo(x, y);
+ } else {
+ eqctx.lineTo(x, y);
+ }
+ }
+ eqctx.strokeStyle = 'black';
+ eqctx.lineWidth = 2.5;
+ eqctx.stroke();
+
+ // Highlight the vertical lines for the low pass and high pass filters
+ eqctx.strokeStyle = accent_col;
+ eqctx.lineWidth = (nearest_filter == 0) ? 5 : 2;
+ drawVerticalLine(freq2x(slice.eq[0].freq));
+ eqctx.lineWidth = (nearest_filter == 5) ? 5 : 2;
+ drawVerticalLine(freq2x(slice.eq[5].freq));
+ eqctx.lineWidth = 1;
+
+ if (G.pitched) {
+ eqctx.fillStyle = inv_accent_col_fill;
+ eqctx.font = (60 * dpr) + 'px Arial';
+ eqctx.fillText(midiToNoteName(slice.base_pitch), 4, eqcanvas.height - 4);
+ const freq = midiToFreq(slice.base_pitch);
+ const x1 = freq2x(freq / 2);
+ const x2 = freq2x(freq * 2);
+ eqctx.fillRect(x1, 0, x2 - x1, eqcanvas.height);
+ eqctx.strokeStyle = inv_accent_col;
+ eqctx.lineWidth = (nearest_filter == -99) ? 5 : 2;
+ drawVerticalLine(freq2x(freq));
+
+ }
+
+
+ // Draw circles for mid filters, larger if selected or highlighted
+ for (let i = 1; i <= 4; i++) {
+ const filter = slice.eq[i];
+ const x = freq2x(filter.freq), y = gain2y(filter.gain);
+ const isHighlighted = (i === nearest_filter);
+ eqctx.beginPath();
+ eqctx.arc(x, y, isHighlighted ? 10 : 7, 0, 2 * Math.PI);
+ eqctx.fillStyle = accent_col;
+ eqctx.fill();
+ }
+}
+function drawHorizontalLine(y) {
+ eqctx.beginPath();
+ eqctx.moveTo(0, y);
+ eqctx.lineTo(eqcanvas.width, y);
+ eqctx.stroke();
+}
+function drawVerticalLine(x) {
+ eqctx.beginPath();
+ eqctx.moveTo(x, 0);
+ eqctx.lineTo(x, eqcanvas.height);
+ eqctx.stroke();
+}
+function is_zero_crossing(sample) {
+ sample = sample | 0;
+ return (sample >= 0 && sample < G.the_audio.length - 1 && G.the_audio[sample] < 0 && G.the_audio[sample + 1] >= 0);
+}
+
+function find_best_snap_point_near(sample) {
+ const bestsample = sample | 0;
+ const snap_range = 4 * dpr * G.zoom;
+ // look for a positive zero crossing
+ for (let radius = 0; radius < snap_range; ++radius) {
+ if (is_zero_crossing(bestsample + radius))
+ return bestsample + radius;
+ if (is_zero_crossing(bestsample - radius))
+ return bestsample - radius;
+ }
+ return bestsample;
+}
+
+eqcanvas.addEventListener('pointermove', (event) => {
+ if (G.currentSlice >= G.slices.length) return;
+ const slice = G.slices[G.currentSlice];
+ const mouseX = (event.offsetX) * dpr;
+ const mouseY = (event.offsetY) * dpr;
+
+ if (!is_dragging) {
+ // Find the closest filter within 10 pixels
+ nearest_filter = -1;
+ let minDistance = 10 * dpr;
+ for (let i = 0; i < 6; i++) {
+ const filter = slice.eq[i];
+ const x = freq2x(filter.freq), y = gain2y(filter.gain);
+ let yscale = (i == 0 || i == 5) ? 0 : 1;
+ const dist = Math.sqrt(Math.pow(x - mouseX, 2) + yscale * Math.pow(y - mouseY, 2));
+ if (dist < minDistance) {
+ nearest_filter = i;
+ minDistance = dist;
+ }
+ }
+ if (G.pitched) {
+ const freq = midiToFreq(slice.base_pitch);
+ const x = freq2x(freq);
+ const dist = Math.abs(x - mouseX);
+ if (dist < minDistance) {
+ nearest_filter = -99;
+ minDistance = dist;
+ }
+ }
+ if (nearest_filter == -1) {
+ const x0 = freq2x(slice.eq[0].freq);
+ const x5 = freq2x(slice.eq[5].freq);
+ if (mouseX < x0) nearest_filter = 0;
+ if (mouseX > x5) nearest_filter = 5;
+ }
+ drawEQCurve(); // Redraw to update highlighting
+ } else if (nearest_filter !== -1) {
+ // Adjust the frequency and gain of the selected filter
+ const filter = nearest_filter >= 0 ? slice.eq[nearest_filter] : null;
+ const deltaX = (event.movementX / eqcanvas.width) * 3 * dpr; // Logarithmic scale factor for X axis
+ const deltaY = -event.movementY * (60 / eqcanvas.height) * dpr; // Linear scale for Y axis
+ if (nearest_filter == -99) {
+ let freq = clamp(midiToFreq(slice.base_pitch) * Math.pow(10, deltaX), 16, 20000);
+ slice.base_pitch = freqToMidi(freq);
+ document.getElementById('basePitchValue').innerText = midiToNoteName(slice.base_pitch);
+ document.getElementById('basePitchSlider').value = slice.base_pitch;
+
+ } else {
+ filter.freq = Math.max(17
+ , Math.min(15000, filter.freq * Math.pow(10, deltaX))); // Clamp frequency between 20Hz and 20kHz
+ filter.gain = Math.max(-36, Math.min(24, filter.gain + deltaY)); // Clamp gain between -60dB and +12dB
+ setupBiquad(filter, nearest_filter); // Recompute coefficients
+ if (nearest_filter == 0) { slice.eq[6].freq = filter.freq; setupBiquad(slice.eq[6], 6); } // Update highpass2
+ if (nearest_filter == 5) { slice.eq[7].freq = filter.freq; setupBiquad(slice.eq[7], 7); } // Update lowpass2
+ }
+ drawEQCurve(); // Redraw curve
+ }
+});
+
+// Handle mouse down
+eqcanvas.addEventListener('pointerdown', (event) => {
+ if (nearest_filter !== -1) {
+ is_dragging = true;
+ // capture the mouse for the drag
+ event.preventDefault();
+ eqcanvas.setPointerCapture(event.pointerId);
+ }
+});
+
+eqcanvas.addEventListener('pointerup', (event) => {
+ is_dragging = false;
+ eqcanvas.releasePointerCapture(event.pointerId);
+});
+
+///////////////////////// WAVEFORM
+
+console.log("hello from waveform.js", wfcanvas.width, wfcanvas.height)
+let pointerDownTime = 0;
+let pointerDownX = 0;
+let isDragging = false;
+let nearestPoint = -1;
+let pointToLeft = -1;
+let hoveredItem = -1;
+let hoveredSlice = -1;
+let hoveredStart = 0;
+let hoveredEnd = 0;
+let hoveredMouseSample = 0;
+
+function drawCurve(points, startx, endx, fillit) {
+ if (G.currentSlice >= G.slices.length) return;
+ const width = endx - startx;
+ // Draw the curve by interpolating between points
+ wfctx.beginPath();
+ wfctx.moveTo(startx, wfcanvas.height * (1 - points[0].y));
+
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = points[i];
+ const p2 = points[i + 1];
+ let x1 = p1.x * width + startx;
+ if (x1 >= wfcanvas.width) break;
+ const x2 = p2.x * width + startx + 0.001;
+ if (x2 < 0) continue;
+ if (nearestPoint == -1 && pointToLeft == i) {
+ const y1 = wfcanvas.height * (1 - p1.y) * 0.5;
+ const y2 = wfcanvas.height * (1 - p2.y) * 0.5;
+ wfctx.fillRect(x1, Math.min(y1, y2), x2 - x1, Math.abs(y2 - y1));
+ }
+ wfctx.lineWidth = (nearestPoint === -1 && pointToLeft === i) ? 4 : 2;
+ wfctx.beginPath();
+ wfctx.moveTo(x1, wfcanvas.height * (1 - p1.y) * 0.5);
+ const dx = dpr * 2;
+ for (let x = Math.max(0, x1); x < wfcanvas.width + dx; x += dx) {
+ let t = (x - x1) / (x2 - x1);
+ if (t > 1) t = 1;
+ const y = p1.y + (p2.y - p1.y) * Math.pow(t, p1.power);
+ const canvasY = wfcanvas.height * (1 - y) * 0.5;
+ wfctx.lineTo(x, canvasY);
+ if (t == 1) break;
+ }
+ wfctx.strokeStyle = 'black';
+ wfctx.stroke();
+ }
+
+ for (let i = 0; i < points.length; i++) {
+ const point = points[i];
+ const x = point.x * width + startx;
+ const y = wfcanvas.height * (1 - point.y) * 0.5;
+ wfctx.beginPath();
+ wfctx.arc(x, y, i === nearestPoint ? 10 : 7, 0, 2 * Math.PI);
+ wfctx.fillStyle = fillit;
+ wfctx.fill();
+ }
+}
+
+let mouseSample = 0;
+let mouseX = 0;
+function do_mouse_move(event, mousedown=false) {
+ let slice = G.currentSlice < G.slices.length ? G.slices[G.currentSlice] : default_slice;
+ const points = slice.volenv;
+ const startx = (slice.start - G.first_sample) / G.zoom;
+ const endx = (slice.end - G.first_sample) / G.zoom;
+ const width = endx - startx;
+ mouseX = event.offsetX * dpr;
+ const mousePos = {
+ x: (mouseX - startx) / width,
+ y: 1 - ((event.offsetY) * dpr) / wfcanvas.height * 2
+ };
+ const newMouseSample = ((event.offsetX) * dpr) * G.zoom + G.first_sample;
+ //let dmouseSample = newMouseSample - mouseSample;
+ mouseSample = newMouseSample;
+ //console.log(mouseSample);
+ if (!isDragging) {
+ let prevnearest = nearestPoint;
+ let prevpointToLeft = pointToLeft;
+ let prevhovered = hoveredItem;
+ let prevhoveredSlice = hoveredSlice;
+ nearestPoint = -1;
+ pointToLeft = -1;
+ hoveredItem = -1;
+ hoveredSlice = -1;
+ let minDist = 10 * dpr / width;
+ if (mousePos.y >= -1 && mousePos.y <= 1) {
+ if (mousePos.y >= 0) {
+ for (let i = 0; i < points.length; i++) {
+ const dist = Math.abs(points[i].x - mousePos.x);
+ const ydist = Math.abs(points[i].y - mousePos.y);
+ if (dist < minDist && ydist < 0.1) {
+ minDist = dist;
+ nearestPoint = i;
+ hoveredItem = 1; // edit point
+ }
+ if (points[i].x < mousePos.x && mousePos.x >= 0 && mousePos.x <= 1) {
+ pointToLeft = i;
+ }
+ }
+ }
+ if (nearestPoint == -1) {
+ if (mousePos.x >= -minDist && mousePos.x < minDist) hoveredItem = 2; // start point
+ else if (mousePos.x >= 1 - minDist && mousePos.x < 1 + minDist) hoveredItem = 3; // end point
+ else if (pointToLeft >= 0) {
+ const p1 = slice.volenv[pointToLeft];
+ const p2 = slice.volenv[pointToLeft + 1];
+ const t = (mousePos.x - p1.x) / (p2.x - p1.x);
+ const y = p1.y + (p2.y - p1.y) * Math.pow(t, p1.power);
+ //const miny = Math.min(p1.y, p2.y) + 5 / wfcanvas.height;
+ //const maxy = Math.max(p1.y, p2.y) - 5 / wfcanvas.height;
+ if (Math.abs(y - mousePos.y) < 10 / 256)
+ hoveredItem = 4; // curve
+ }
+ }
+ hoveredMouseSample = mouseSample;
+ hoveredStart = slice.start;
+ hoveredEnd = slice.end;
+ if (hoveredItem == -1 && mousePos.x >= 0 && mousePos.x <= 1)
+ hoveredItem = 5; // drag whole slice
+ if (hoveredItem != 4) pointToLeft = -1;
+ if (hoveredItem != 1) nearestPoint = -1;
+ if (hoveredItem == -1) {
+ // look to see if we are over any of the slices
+ for (let j = ((G.currentSlice >= G.slices.length) ? 0 : 1); j < G.slices.length; j++) {
+ const i = (G.currentSlice + j) % G.slices.length;
+ const otherslice = G.slices[i];
+ if (mouseSample >= otherslice.start && mouseSample < otherslice.end) {
+ hoveredSlice = i;
+ hoveredItem = 6;
+ if (mousedown) {
+ if (mouseSample < otherslice.start + G.zoom * 10) {
+ console.log("!!!2");
+ hoveredItem = 2;
+ G.currentSlice = i;
+ } else if (mouseSample > otherslice.end - G.zoom * 10) {
+ console.log("!!!3");
+ hoveredItem = 3;
+ G.currentSlice = i;
+ }
+ }
+ break;
+ }
+ }
+ }
+ }
+ //console.log(nearestPoint, pointToLeft, hoveredItem, mousePos.x, mousePos.y);
+ if (prevnearest !== nearestPoint || prevpointToLeft !== pointToLeft || prevhovered !== hoveredItem || prevhoveredSlice !== hoveredSlice) {
+ console.log("redrawing", nearestPoint, pointToLeft, hoveredItem, hoveredSlice);
+ renderAudio();
+ }
+ } else {
+ // dragging!
+ if (hoveredItem === 6 || hoveredItem === -1) {
+ const dragdist = event.offsetX - pointerDownX;
+ if (Math.abs(dragdist) > 5) {
+ let name = "noname";
+ for (let i = 0; i < G.filename_list.length; i++) {
+ if (G.filename_list[i].start <= mouseSample && G.filename_list[i].end >= mouseSample) {
+ name = G.filename_list[i].name;
+ break;
+ }
+ }
+ let newslice = {
+ name: name,
+ start: find_best_snap_point_near(Math.min(hoveredMouseSample, mouseSample)),
+ end: find_best_snap_point_near(Math.max(hoveredMouseSample, mouseSample)),
+ gain: 0,
+ eq: JSON.parse(JSON.stringify(default_eq)),
+ volenv: JSON.parse(JSON.stringify(get_default_volenv())),
+ reversed: false,
+ base_pitch: 48,
+ };
+ if (!G.contiguous_slices || G.slices.length == 0) {
+ // make a slice the old way
+ G.slices.push(newslice);
+ G.currentSlice = G.slices.length - 1;
+ hoveredItem = (dragdist < 0) ? 2 : 3;
+ } else if (G.slices.length >= 8) {
+ // dont to anything
+ } else {
+ let i=0;
+ for (;i<G.slices.length;i++) {
+ if (mouseSample <= G.slices[i].start) {
+ break;
+ }
+ }
+ // insert a slice before slice i
+ if (i<G.slices.length) {
+ newslice.end=G.slices[i].start;
+ hoveredItem=2;
+ } else {
+ newslice.start=G.slices[i-1].end;
+ hoveredItem=3;
+ }
+ G.slices.splice(i, 0, newslice);
+ G.currentSlice = i;
+ }
+ updateSliceList();
+ slice = G.slices[G.currentSlice];
+ }
+
+ }
+ function clampslice() {
+ slice.end = Math.max(0, Math.min(G.the_audio.length, slice.end));
+ slice.start = Math.max(0, Math.min(slice.end, slice.start));
+ enforceSliceConstraints();
+ }
+ clampslice();
+ if (hoveredItem === 5) {
+ slice.start = find_best_snap_point_near(Math.max(0, Math.min(slice.end - 1, hoveredStart + mouseSample - hoveredMouseSample)));
+ slice.end = find_best_snap_point_near(Math.max(0, Math.max(slice.start + 1, hoveredEnd + mouseSample - hoveredMouseSample)));
+ clampslice();
+ updateAudioDisplay();
+ }
+ if (hoveredItem === 2) {
+ slice.start = find_best_snap_point_near(Math.max(0, Math.min(slice.end - 1, mouseSample)));
+ clampslice();
+ updateAudioDisplay();
+ }
+ else if (hoveredItem === 3) {
+ slice.end = find_best_snap_point_near(Math.max(0, Math.max(slice.start + 1, mouseSample)));
+ clampslice();
+ updateAudioDisplay();
+ }
+ else if (hoveredItem === 1) {
+ // Update the coordinates of the nearest point
+ // if (nearestPoint === 0)
+ // slice.start = Math.max(0, Math.min(slice.end - 1, mouseSample));
+ // else if (nearestPoint === points.length - 1)
+ // slice.end = Math.max(0, Math.max(slice.start + 1, mouseSample));
+ // else
+ if (nearestPoint > 0 && nearestPoint < points.length - 1) {
+ points[nearestPoint].x = Math.max(points[nearestPoint - 1].x, Math.min(mousePos.x, points[nearestPoint + 1].x));
+ }
+ points[nearestPoint].y = Math.max(0, Math.min(mousePos.y, 1));
+ updateAudioDisplay();
+ } else if (hoveredItem == 4 && pointToLeft !== -1 && pointToLeft < points.length - 1) {
+ // Update the power of the point to the left
+ const p1 = points[pointToLeft];
+ const p2 = points[pointToLeft + 1];
+ if (p1.x < p2.x) {
+ const eps = 0.0001;
+ const t = Math.min(1 - eps, Math.max(eps, (mousePos.x - p1.x) / (p2.x - p1.x)));
+ const miny = Math.min(p1.y, p2.y) + 1 / wfcanvas.height;
+ const maxy = Math.max(p1.y, p2.y) - 1 / wfcanvas.height;
+ const clampedY = Math.max(miny, Math.min(mousePos.y, maxy));
+ p1.power = Math.log((clampedY - p1.y) / (p2.y - p1.y)) / Math.log(t);
+ if (mousePos.y > maxy + 0.1 || mousePos.y < miny - 0.1) {
+ // you've slammed into the vertical limits hard, maybe you want to create a new point?
+ const newPoint = {
+ x: mousePos.x,
+ y: mousePos.y,
+ power: points[pointToLeft].power
+ };
+ points.splice(pointToLeft + 1, 0, newPoint);
+ nearestPoint = pointToLeft + 1;
+ hoveredItem = 1;
+ pointToLeft = -1;
+ }
+ updateAudioDisplay();
+ }
+ }
+ }
+}
+wfcanvas.addEventListener('pointermove', (event) => {
+ do_mouse_move(event);
+});
+let isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
+if (isFirefox)
+ document.getElementById('help').innerHTML = 'Please click here before dropping files.';
+document.getElementById('help').addEventListener('click', makeAudioContext);
+
+wfcanvas.addEventListener('pointerdown', (event) => {
+ console.log("pointerdown");
+ makeAudioContext();
+ event.preventDefault();
+ wfcanvas.setPointerCapture(event.pointerId);
+ do_mouse_move(event, true);
+ isDragging = true;
+ pointerDownTime = Date.now();
+ pointerDownX = event.offsetX;
+});
+
+wfcanvas.addEventListener('pointerup', (event) => {
+ event.preventDefault();
+ wfcanvas.releasePointerCapture(event.pointerId);
+ isDragging = false;
+ if (hoveredItem == 6) {
+ selectSlice(hoveredSlice);
+ }
+ enforceSliceConstraints();
+ //console.log("pointer up", G.slices.length, G.slices[0].start, G.slices[0].end, G.slices[1].start, G.slices[1].end);
+
+});
+
+wfcanvas.addEventListener('dblclick', (event) => {
+ if (G.currentSlice >= G.slices.length) return;
+ const slice = G.currentSlice < G.slices.length ? G.slices[G.currentSlice] : default_slice;
+ const points = slice.volenv;
+ console.log("dblclick");
+ const startx = (slice.start - G.first_sample) / G.zoom;
+ const endx = (slice.end - G.first_sample) / G.zoom;
+ const width = endx - startx;
+ const rect = wfcanvas.getBoundingClientRect();
+ const mousePos = {
+ x: ((event.clientX - rect.left) * dpr - startx) / width,
+ y: 1 - ((event.clientY - rect.top) * dpr) / wfcanvas.height * 2
+ };
+ const splitSample = find_best_snap_point_near(mouseSample);
+ if (hoveredItem == 5 && splitSample > slice.start && splitSample < slice.end) {
+ // split the current spliace
+ const newSlice = JSON.parse(JSON.stringify(slice));
+ newSlice.start = splitSample;
+ G.slices.splice(G.currentSlice + 1, 0, newSlice);
+ slice.end = splitSample;
+ if (newSlice.end - newSlice.start > slice.end - slice.start)
+ G.currentSlice++;
+ updateSliceList();
+ updateAudioDisplay();
+ return;
+ }
+ if (hoveredItem == 1 && nearestPoint !== -1 && nearestPoint !== 0 && nearestPoint !== points.length - 1) {
+ console.log("delete point");
+ // Delete the nearest point if it is not the first or last point
+ points.splice(nearestPoint, 1);
+ nearestPoint = -1;
+ } else if (nearestPoint === -1 && pointToLeft !== -1) {
+ // Insert a new point after pointToLeft
+ const newPoint = {
+ x: mousePos.x,
+ y: mousePos.y,
+ power: points[pointToLeft].power
+ };
+ points.splice(pointToLeft + 1, 0, newPoint);
+ }
+ console.log("end dblclick");
+ updateAudioDisplay();
+});
+
+
+document.addEventListener('dragover', (event) => {
+ event.preventDefault();
+});
+
+document.addEventListener('drop', async (event) => {
+ console.log("drop");
+ makeAudioContext();
+ event.preventDefault();
+ if (event.dataTransfer.items) {
+ const old_audio_length = G.the_audio.length;
+ G.currentSlice = G.slices.length;
+ const files = [];
+ for (let item of event.dataTransfer.items) {
+ if (item.kind === 'file') {
+ const file = item.getAsFile();
+ if (file)
+ files.push(file);
+ }
+ }
+ for (let file of files) {
+ console.log("ok im going to do it", file);
+ await processAudioFile(file);
+ }
+ const new_audio_length = G.the_audio.length;
+ ensureSliceVisible(old_audio_length, new_audio_length, old_audio_length==0);
+ updateSliceList();
+ updateAudioDisplay();
+ }
+});
+let lastwheelevent = 0;
+wfcanvas.addEventListener('wheel', (event) => {
+ lastwheelevent = event.timeStamp;
+ event.preventDefault();
+ if (event.deltaY !== 0) {
+ // Zoom adjustment
+ mouseX = event.offsetX * dpr;
+ mouseSample = G.first_sample + mouseX * G.zoom;
+ const max_zoom = G.the_audio.length / wfcanvas.width * 5; // let them zoom out until only 20% of the viewport is filled
+ const was_bigger = G.zoom > max_zoom;
+ G.zoom *= Math.pow(1.1, -event.deltaY / 100);
+ if (!was_bigger)
+ G.zoom = Math.min(max_zoom, G.zoom);
+ G.zoom = Math.max(0.1, G.zoom);
+ G.first_sample = mouseSample - mouseX * G.zoom;
+ } else if (event.deltaX !== 0) {
+ G.first_sample += event.deltaX * G.zoom;
+ }
+ const margin = 10 * dpr * G.zoom;
+ const minfs = -wfcanvas.width * G.zoom + margin;
+ const maxfs = G.the_audio.length - margin;
+ G.first_sample = Math.max(minfs, Math.min(G.first_sample, maxfs));
+
+ computeBrightnessArray();
+ renderAudio();
+});
+
+function makeAudioContext() {
+ if (!audioContext) {
+ if (isFirefox)
+ document.getElementById('help').innerHTML = 'Now drop one or more audio files here to edit them.';
+ audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: sampleRate });
+ }
+}
+
+async function processAudioFile(file) {
+ console.log("processAudioFile");
+ if (isFirefox && !audioContext)
+ return;
+ makeAudioContext();
+ const arrayBuffer = await file.arrayBuffer();
+ let audioBuffer;
+ try {
+ audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+ } catch (error) {
+ // not an audio file?
+ console.log("error decoding audio data", error);
+ return;
+ }
+ const monoAudio = mixDownToMono(audioBuffer);
+ const resampledAudio = G.wavetablemode ? await resampleAudio(monoAudio, audioBuffer.sampleRate, sampleRate) : monoAudio;
+ for (let i = 0; i < resampledAudio.length; i++) {
+ //resampledAudio[i] = Math.atanh(Math.max(-1, Math.min(1, resampledAudio[i])) * 0.97) / 0.97; // undo the soft clipper :) this actually makes the full range +-2
+ if (isNaN(resampledAudio[i])) {
+ resampledAudio[i] = 0;
+ console.log("nan at", i);
+ }
+ }
+ G.filename_list.push({ name: file.name, start: G.the_audio.length, end: G.the_audio.length + resampledAudio.length });
+ const new_audio = new Float32Array(G.the_audio.length + resampledAudio.length);
+ new_audio.set(G.the_audio);
+ const start = G.the_audio.length;
+ const end = new_audio.length;
+ new_audio.set(resampledAudio, start);
+ G.the_audio = new_audio;
+ //let bpm = sampleRate / resampledAudio.length * 60;
+ //while (bpm < 90) bpm *= 2;
+ //while (bpm > 180) bpm /= 2;
+ if (1) G.slices.push(
+ {
+ name: file.name,
+ start: start, end: end,
+ gain: 0,
+ eq: JSON.parse(JSON.stringify(default_eq)),
+ volenv: JSON.parse(JSON.stringify(get_default_volenv())),
+ reversed: false,
+ base_pitch: 48,
+ },
+ );
+
+}
+
+function updateAudioDisplay() {
+ computeBrightnessArray();
+ if (!isPlaying && !anim_requested) { // if playing, it renders anyway.
+ anim_requested = true;
+ requestAnimationFrame(renderAudio);
+ }
+}
+
+function mixDownToMono(audioBuffer) {
+ const numberOfChannels = audioBuffer.numberOfChannels;
+ const length = audioBuffer.length;
+ const mono = new Float32Array(length);
+ for (let i = 0; i < numberOfChannels; i++) {
+ const channelData = audioBuffer.getChannelData(i);
+ for (let j = 0; j < length; j++) {
+ mono[j] += channelData[j] / numberOfChannels;
+ }
+ }
+ return mono;
+}
+
+async function resampleAudio(audioData, originalSampleRate, targetSampleRate) {
+ if (originalSampleRate === targetSampleRate) {
+ return audioData;
+ }
+
+ const ratio = targetSampleRate / originalSampleRate;
+ const targetLength = Math.round(audioData.length * ratio);
+ const resampledAudio = new Float32Array(targetLength);
+
+ const offlineContext = new OfflineAudioContext(1, targetLength, targetSampleRate);
+ const buffer = offlineContext.createBuffer(1, audioData.length, originalSampleRate);
+ buffer.copyToChannel(audioData, 0);
+
+ const source = offlineContext.createBufferSource();
+ source.buffer = buffer;
+ source.connect(offlineContext.destination);
+ source.start(0);
+
+ const renderedBuffer = await offlineContext.startRendering();
+ renderedBuffer.copyFromChannel(resampledAudio, 0);
+
+ return resampledAudio;
+}
+
+function computeBrightnessArray() {
+
+ // also update minimap
+ const minimap = new Uint8Array(2048);
+ const start = G.slices.length > 0 ? G.slices[0].start : 0;
+ const end = Math.min(G.the_audio.length, start + 2 * 1024 * 1024);
+ let gain=1;
+ let curslice = -1;
+ let next_slice_start = G.slices.length > 0 ? G.slices[0].start : end;
+ let cur_slice_end = end;
+ for (let i=start;i<end;i+=1024) {
+ if (i>=next_slice_start) {
+ curslice++;
+ next_slice_start = (curslice < G.slices.length - 1) ? G.slices[curslice+1].start : end;
+ cur_slice_end = (curslice < G.slices.length) ? G.slices[curslice].end : end;
+ gain = db2lin(G.slices[curslice].gain);
+ }
+ if (i>=cur_slice_end)
+ gain=1;
+ let max = 0;
+ for (let j=0;j<1024;j++) {
+ max = Math.max(max, Math.abs(G.the_audio[i+j] * gain));
+ }
+ minimap[(i-start)/1024] = (max*16)|0;
+ }
+ tab_states[selectedTab].minimap = minimap;
+ renderMiniCanvas(selectedTab, minimap);
+
+ imageData = null;
+ brightnessArray = null;
+ default_slice.start = 0;
+ default_slice.end = 1;
+ const slice = G.currentSlice < G.slices.length ? G.slices[G.currentSlice] : default_slice;
+ const points = slice.volenv;
+ const pregain = db2lin(slice.gain);
+ const width = wfcanvas.width;
+ const height = wfcanvas.height;
+ brightnessArray = new Uint8ClampedArray(width * height * 4);
+ const N = Math.max(1, G.the_audio.length);
+ const W = width;
+ const H = height;
+ const startx = (slice.start - G.first_sample) / G.zoom;
+ const endx = (slice.end - G.first_sample) / G.zoom;
+ const swidth = Math.max(endx - startx, 0.00001);
+ let curpoint = 0;
+ for (let x = 0; x < W; x++) {
+ let sampleIndex = G.first_sample + x * G.zoom;
+ let postgain = null;
+ let thezoom = G.zoom / 100;
+ if (x >= startx && x < endx) {
+ const fracx = (x - startx) / swidth;
+ while (curpoint < points.length - 2 && points[curpoint + 1].x < fracx) curpoint++;
+ const p1 = points[curpoint];
+ const p2 = points[curpoint + 1];
+ const t = (fracx - p1.x) / (p2.x - p1.x);
+ postgain = p1.y + (p2.y - p1.y) * Math.pow(t, p1.power);
+ if (slice.reversed) {
+ thezoom = -thezoom;
+ sampleIndex = slice.end - (sampleIndex - slice.start);
+ }
+ }
+ for (let f = 0; f < 100; f++, sampleIndex += thezoom) {
+ if (sampleIndex < N - 1 && sampleIndex >= 0) {
+ const sample0 = G.the_audio[sampleIndex | 0];
+ const sample1 = G.the_audio[(sampleIndex + 1) | 0];
+ let sample = sample0 + (sample1 - sample0) * (sampleIndex - (sampleIndex | 0));
+ if (postgain != null) {
+ sample = /*Math.tanh*/(sample * pregain) * postgain;
+ } else {
+ sample = /*Math.tanh*/(sample);
+ }
+ if (sample < -1) sample = -1;
+ else if (sample>1) sample = 1;
+ const yf = ((1 - sample) / 2) * (H - 1);
+ const y = yf | 0;
+ const index = (y * W + x) * 4;
+ const i = (yf - y) * 16;
+ brightnessArray[index + 3] += 16 - i; // alpha
+ brightnessArray[index + 3 + W * 4] += i; // alpha
+ }
+ }
+ }
+ for (let i = 3; i < W * H * 4; i += 4) {
+ brightnessArray[i] = (brightnessArray[i] * 255 / (brightnessArray[i] + 16)) | 0;
+ }
+ imageData = new ImageData(brightnessArray, width, height);
+}
+let anim_requested = false;
+function renderAudio(playheadPosition = null) {
+ anim_requested = false;
+ drawEQCurve();
+ const width = wfcanvas.width;
+
+ if (!imageData)
+ computeBrightnessArray();
+ if (!imageData)
+ return;
+ const height = wfcanvas.height;
+ wfctx.putImageData(imageData, 0, 0);
+ const slice = G.currentSlice < G.slices.length ? G.slices[G.currentSlice] : default_slice;
+ const points = slice.volenv;
+ wfctx.globalCompositeOperation = 'darken';
+ wfctx.fillStyle = "lightgray";
+ wfctx.strokeStyle = "gray";
+ wfctx.lineWidth = 0.5;
+ wfctx.font = (8 * dpr) + 'px Arial';
+ for (let i = 0; i < G.filename_list.length; i++) {
+ const start = G.filename_list[i].start;
+ const end = G.filename_list[i].end;
+ const name = G.filename_list[i].name;
+ const startx = (start - G.first_sample) / G.zoom;
+ // wfctx.moveTo(startx, 0);
+ // wfctx.lineTo(startx, height);
+ // wfctx.stroke();
+ wfctx.fillText(name, startx + 4, 12 * dpr);
+ }
+ wfctx.strokeStyle = "black";
+ wfctx.lineWidth = 3;
+ for (let slicei = 0; slicei < G.slices.length; slicei++) {
+
+ const otherslice = G.slices[slicei];
+ const alpha = 0.1; // (hoveredItem == 6 && hoveredSlice == slicei) ? 0.25 : 0.1;
+ wfctx.fillStyle = `hsl(${slicei * 45}, 5%, 60%, ${alpha})`;
+ const startx = (otherslice.start - G.first_sample) / G.zoom;
+ const endx = (otherslice.end - G.first_sample) / G.zoom;
+ if (G.pitched) {
+ wfctx.font = (60 * dpr) + 'px Arial';
+ wfctx.fillText(midiToNoteName(otherslice.base_pitch), startx + 4, height - 4);
+ }
+
+ if (slicei == G.currentSlice) continue;
+ wfctx.fillRect(startx, 0, endx - startx, height);
+ wfctx.moveTo(startx, 0);
+ wfctx.lineTo(startx, height);
+ wfctx.moveTo(endx, 0);
+ wfctx.lineTo(endx, height);
+ wfctx.stroke();
+ }
+
+ wfctx.fillStyle = `hsl(${G.currentSlice * 45}, 70%, 50%, 0.1)`;
+ wfctx.strokeStyle = `hsl(${G.currentSlice * 45}, 70%, 50%)`;
+ fullcol = `hsl(${G.currentSlice * 45}, 70%, 50%)`;
+ const startx = (slice.start - G.first_sample) / G.zoom;
+ const endx = (slice.end - G.first_sample) / G.zoom;
+ wfctx.fillRect(startx, 0, endx - startx, height);
+ // if (slice.type == 'drumloop') {
+ // const dx = (sampleRate / slice.bpm * 60) / G.zoom;
+ // if (dx > 2) for (let x = startx; x < endx; x += dx) {
+ // const newx = Math.min(endx, x + dx * 0.5);
+ // wfctx.fillRect(x, 0, newx - x, height);
+ // }
+ // }
+ // else
+ wfctx.lineWidth = (hoveredItem == 2) ? 4 : 2;
+ wfctx.beginPath();
+ wfctx.moveTo(startx, 0);
+ wfctx.lineTo(startx, height);
+ wfctx.stroke();
+ wfctx.lineWidth = (hoveredItem == 3) ? 4 : 2;
+ wfctx.beginPath();
+ wfctx.moveTo(endx, 0);
+ wfctx.lineTo(endx, height);
+ wfctx.stroke();
+ wfctx.lineWidth = 1;
+ //wfctx.strokeRect(startx, -5, endx - startx, height + 10);
+ wfctx.globalCompositeOperation = 'source-over';
+ drawCurve(points, startx, endx, fullcol);
+ if (isPlaying && analyserNode) {
+ renderFrequencyAnalysis();
+ }
+
+ if (playheadPosition !== null && isPlaying) {
+ wfctx.lineWidth = 1;
+ wfctx.strokeStyle = fullcol;
+ wfctx.beginPath();
+ wfctx.moveTo(playheadPosition, 0);
+ wfctx.lineTo(playheadPosition, height);
+ wfctx.stroke();
+ }
+}
+
+function renderFrequencyAnalysis() {
+ analyserNode.getFloatFrequencyData(frequencyData);
+ const width = fftcanvas.width;
+ const height = fftcanvas.height;
+ const numBins = frequencyData.length;
+ fftctx.clearRect(0, 0, width, height);
+ if (!G.slices.length) return;
+ const slice = G.slices[G.currentSlice];
+ fftctx.fillStyle = `hsl(${G.currentSlice * 45}, 70%, 50%, 0.1)`;
+ fftctx.strokeStyle = `hsl(${G.currentSlice * 45}, 70%, 50%, 0.8)`;
+ fftctx.beginPath();
+ fftctx.moveTo(0, height);
+ for (let i = 0; i < numBins; i++) {
+ if (frequencyData[i] > frequencyDatapeak[i]) {
+ frequencyDatapeak[i] = frequencyData[i];
+ frequencyDatahold[i] = 100;
+ } else {
+ frequencyDatahold[i]--;
+ if (frequencyDatahold[i] < 0) {
+ frequencyDatapeak[i] = frequencyData[i];
+ }
+ }
+ const freq = (i / numBins) * 16000;
+ const x = freq2x(freq);
+ const magnitude = gain2y(frequencyData[i] + 30); // Adjust magnitude to fit in fftcanvas
+ fftctx.lineTo(x, magnitude);
+ }
+ fftctx.lineTo(width, height);
+ fftctx.stroke();
+ fftctx.closePath();
+ fftctx.fill();
+ fftctx.moveTo(0, height);
+ for (let i = 0; i < numBins; i++) {
+ const freq = (i / numBins) * 16000;
+ const x = freq2x(freq);
+ const magnitude = gain2y(frequencyDatapeak[i] + 30); // Adjust magnitude to fit in fftcanvas
+ fftctx.lineTo(x, magnitude);
+ }
+ fftctx.stroke();
+}
+
+function sinc(x) {
+ return x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x);
+}
+
+function generateSincCoefficients(numTaps, cutoff) {
+ const coefficients = new Float32Array(numTaps);
+ const center = (numTaps - 1) / 2;
+
+ let sum = 0;
+ for (let i = 0; i < numTaps; i++) {
+ coefficients[i] = sinc((i - center) * cutoff);
+ sum += coefficients[i];
+ }
+
+ // Normalize coefficients
+ for (let i = 0; i < numTaps; i++) {
+ coefficients[i] /= sum;
+ }
+
+ return coefficients;
+}
+
+const sinc_fir = generateSincCoefficients(16, 0.5);
+console.log(sinc_fir);
+function decimate2x(input) {
+ const numTaps = sinc_fir.length;
+ const sincCoefficients = sinc_fir;
+ const outputLength = input.length / 2;
+ const output = new Float32Array(outputLength);
+ for (let i = 0; i < outputLength; i++) {
+ let sum = 0;
+ for (let j = 0; j < numTaps; j++) {
+ const idx = (2 * i - j + input.length) % input.length; // Wrap index
+ sum += input[idx] * sincCoefficients[j];
+ }
+ output[i] = sum;
+ }
+ return output;
+}
+
+function resample_slice(audio_slice, new_length) {
+ const resampled = new Float32Array(new_length);
+ for (let i = 0; i < new_length; i++) {
+ const t = i / new_length;
+ const sampleIndex = t * (audio_slice.length);
+ const sample0 = audio_slice[sampleIndex | 0];
+ const sample1 = audio_slice[((sampleIndex + 1) | 0) % audio_slice.length];
+ resampled[i] = sample0 + (sample1 - sample0) * (sampleIndex - (sampleIndex | 0));
+ }
+ return resampled;
+}
+
+function decimate_2x(audio_slice) {
+ const resampled = new Float32Array(audio_slice.length / 2);
+ for (let i = 0; i < resampled.length; i++) {
+ resampled[i] = audio_slice[i * 2];
+ }
+ return resampled;
+}
+
+function playAudio() {
+ makeAudioContext();
+ console.log('playAudio');
+ if (!G.the_audio.length || !audioContext) return;
+ if (isPlaying) {
+ audioSource.stop();
+ isPlaying = false;
+ if (playButton) playButton.textContent = 'Play';
+ //fftcanvas.style.display = 'none';
+ return;
+ }
+ if (G.currentSlice >= G.slices.length) return;
+ (async () => {
+ if (!customNode) {
+ console.log("LOADING WORKLET");
+ await audioContext.audioWorklet.addModule('processor.js');
+ customNode = new AudioWorkletNode(audioContext, 'custom-processor', {
+ parameterData: {
+ gain: 1,
+ }
+ });
+ }
+ // make a slice of the_audio
+ const slice = G.slices[G.currentSlice];
+ const volenv = slice.volenv;
+ let audio_slice = G.the_audio.slice(slice.start | 0, slice.end | 0);
+ if (slice.reversed)
+ audio_slice = new Float32Array(audio_slice).reverse();
+ if (G.wavetablemode) {
+ console.log("resampling");
+ audio_slice = decimate_2x(decimate_2x(decimate_2x(resample_slice(audio_slice, 2048))));
+ const new_audio_slice = new Float32Array(audio_slice.length * 256);
+ for (let i = 0; i < 256; i++) {
+ new_audio_slice.set(audio_slice, i * audio_slice.length);
+ }
+ audio_slice = new_audio_slice;
+ }
+ const audioBuffer = audioContext.createBuffer(1, audio_slice.length, sampleRate);
+ audioBuffer.copyToChannel(audio_slice, 0);
+ audioSource = audioContext.createBufferSource();
+ audioSource.buffer = audioBuffer;
+ console.log("samplerate", audioContext.sampleRate);
+ analyserNode = audioContext.createAnalyser({ fftSize: 4096, minDecibels: -90, maxDecibels: -20, smoothingTimeConstant: 0.8 });
+ analyserNode.fftSize = 4096;
+ frequencyData = new Float32Array(analyserNode.frequencyBinCount);
+ frequencyDatapeak = new Float32Array(analyserNode.frequencyBinCount);
+ frequencyDatahold = new Int32Array(analyserNode.frequencyBinCount);
+ gainvalue = db2lin(slice.gain);
+ console.log("gainvalue", gainvalue);
+ if (customNode) {
+ customNode.disconnect();
+ customNode.port.postMessage({ type: 'volumeEnvelope', dx: 1 / (slice.end - slice.start), envelope: slice.volenv, wavetablemode: G.wavetablemode });
+ audioSource.connect(customNode);
+ customNode.connect(analyserNode);
+ setupBiquads();
+ customNode.parameters.get('gain').setValueAtTime(gainvalue, 0);
+ } else {
+ audioSource.connect(analyserNode);
+ }
+ analyserNode.connect(audioContext.destination);
+
+ audioSource.start();
+
+ isPlaying = true;
+ if (playButton) playButton.textContent = 'Stop';
+ //fftcanvas.style.display = 'block';
+
+ const startTime = audioContext.currentTime;
+ const duration = audioBuffer.duration;
+
+ function updatePlayhead() {
+ if (!isPlaying) return;
+ const currentTime = audioContext.currentTime - startTime;
+ if (currentTime >= duration) {
+ isPlaying = false;
+ if (playButton) playButton.textContent = 'Play';
+ //fftcanvas.style.display = 'none';
+ return;
+ }
+ const playheadSample = currentTime * sampleRate + slice.start; // Current sample based on time and sample rate
+ let playheadPosition = Math.floor((playheadSample - G.first_sample) / G.zoom);
+ // const scrollx = wfcanvas.width * 0.75;
+ // if (playheadPosition > scrollx && lastwheelevent < Date.now() - 1000) {
+ // playheadPosition = scrollx;
+ // G.first_sample = playheadSample - playheadPosition * G.zoom;
+ // if (G.first_sample < 0) {
+ // G.first_sample = 0;
+ // playheadPosition = Math.floor((playheadSample - G.first_sample) / G.zoom);
+ // }
+ // imageData = null;
+ // }
+ renderAudio(playheadPosition);
+ anim_requested = true;
+ requestAnimationFrame(updatePlayhead);
+ }
+ updatePlayhead();
+ audioSource.onended = () => {
+ isPlaying = false;
+ if (playButton) playButton.textContent = 'Play';
+ //fftcanvas.style.display = 'none';
+ renderAudio();
+ };
+
+ })();
+
+}
+if (playButton) playButton.addEventListener('click', playAudio);
+
+
+
diff --git a/sample_editor/topbar.css b/sample_editor/topbar.css
new file mode 100644
index 0000000..88ec5be
--- /dev/null
+++ b/sample_editor/topbar.css
@@ -0,0 +1,93 @@
+:root {
+ --highlight-hue: 0;
+}
+body {
+ background: white;
+ font-family: 'Barlow Condensed', sans-serif;
+ font-size: 12px;
+ margin: 0;
+}
+.top-menu-bar {
+ width: 100vw;
+ height: 24px;
+ display: flex;
+}
+.tab {
+ height: 23px;
+ display: flex;
+ align-items: center;
+ border: 1px solid hsl(var(--highlight-hue), 100%, 50%);
+ border-right: none;
+ background: hsl(var(--highlight-hue), 100%, 50%);
+ color: white;
+ cursor: pointer;
+ transition: background 0.3s;
+ flex-grow: 1;
+}
+.tab:last-child {
+ border-right: 1px solid hsl(var(--highlight-hue), 100%, 50%);
+}
+.tab:hover {
+ background: lightgrey;
+}
+.tab.selected {
+ background: white;
+ color: hsl(var(--highlight-hue), 100%, 50%);
+}
+.burger-menu {
+ width: 24px;
+ justify-content: center;
+ flex-shrink: 0;
+ flex-grow: 0;
+}
+.burger-menu-icon {
+ font-size: 16px;
+ color: white;
+}
+.dropdown-menu {
+ display: none;
+ position: absolute;
+ top: 24px;
+ left: 0;
+ background: white;
+ border: 1px solid hsl(var(--highlight-hue), 100%, 50%);
+ z-index: 1;
+}
+.dropdown-menu.open {
+ display: block;
+}
+.dropdown-item {
+ padding: 4px 8px;
+ border-bottom: 1px solid hsl(var(--highlight-hue), 100%, 50%);
+ cursor: pointer;
+ font-size: 14px;
+}
+.dropdown-item:last-child {
+ border-bottom: none;
+}
+.dropdown-item:hover {
+ background: lightgrey;
+}
+.circle {
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: white;
+ color: hsl(var(--highlight-hue), 100%, 50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 5px;
+ margin-left: 2px;
+ transition: background 0.3s, color 0.3s;
+}
+.tab.selected .circle {
+ background: hsl(var(--highlight-hue), 100%, 50%);
+ color: white;
+}
+.minicanvas-container {
+ flex-grow: 1;
+ width: 22px;
+ height: 22px;
+ opacity: 0.25;
+}