loopstation / index.html
SignorNessuno's picture
Add 2 files
dd5133c verified
Raw History Blame Contribute Delete
55.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Advanced Audio Looper</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.8.49/Tone.js"></script>
<script src="https://cdn.jsdelivr.net/npm/recordrtc@5.6.2/RecordRTC.min.js"></script>
<style>
:root {
--background-color: #1a1a1a;
--grid-background: #2a2a2a;
--cell-color: #444;
/* Percussion Colors */
--cell-active-kick: #ff6b6b; /* Red */
--cell-active-snare: #ffa07a; /* Light Salmon */
--cell-active-clap: #ffd700; /* Gold */
--cell-active-hihat: #98fb98; /* Pale Green */
--cell-active-tom: #ff8c00; /* Dark Orange */
--cell-active-crash: #00bfff; /* Deep Sky Blue */
--cell-active-ride: #9370db; /* Medium Purple */
--cell-active-perc: #ff69b4; /* Hot Pink */
/* Note Color */
--cell-active-note: #4ecdc4; /* Teal */
--cell-highlight: #666;
--text-color: #e0e0e0;
--control-background: #333;
--button-color: #555;
--button-hover: #666;
--button-active: #4ecdc4; /* Teal for general active state */
--button-drum-active: #ff8c00; /* Dark Orange for active drum tool */
--button-demo-color: #8a2be2; /* Blue Violet for Demo button */
--button-demo-hover: #9932cc; /* Dark Orchid */
--button-record-color: #ff4444; /* Red for Record button */
--button-record-active: #cc0000; /* Darker red when recording */
--font-family: 'Roboto', sans-serif;
/* Default grid cell size */
--cell-size: 40px;
--grid-gap: 5px;
}
/* Basic Reset and Box Sizing */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%; /* Ensure body takes full height */
}
body {
font-family: var(--font-family);
background-color: var(--background-color);
color: var(--text-color);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 10px; /* Add padding for smaller screens */
/* Allow scrolling if content overflows */
overflow-y: auto;
min-height: 100vh;
/* Improve touch behavior on iOS */
-webkit-tap-highlight-color: transparent;
}
.looper-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20px;
padding: 20px;
background-color: var(--grid-background);
border-radius: 10px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
width: 100%; /* Take full width available */
max-width: 1000px; /* Increased max width */
}
.grid-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
width: 100%;
}
.grid-container {
width: 100%;
overflow-x: auto;
padding-bottom: 10px;
}
.grid {
display: grid;
/* Calculate grid size based on variables */
grid-template-columns: repeat(var(--num-cols, 8), var(--cell-size));
grid-template-rows: repeat(var(--num-rows, 8), var(--cell-size));
gap: var(--grid-gap);
background-color: var(--grid-background);
padding: 10px;
border-radius: 8px;
border: 1px solid var(--cell-color);
width: fit-content; /* Fit the content size */
min-width: 100%;
margin: 0 auto;
}
.cell {
background-color: var(--cell-color);
border-radius: 4px;
cursor: pointer;
transition: background-color 0.1s ease-in-out, transform 0.1s ease, box-shadow 0.1s ease;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
position: relative;
/* Ensure cells maintain aspect ratio */
width: var(--cell-size);
height: var(--cell-size);
}
.cell:hover {
filter: brightness(1.2);
}
/* Active Cell Styling */
.cell.active {
filter: brightness(1.1);
}
.cell.active.kick { background-color: var(--cell-active-kick); box-shadow: 0 0 8px var(--cell-active-kick); }
.cell.active.snare { background-color: var(--cell-active-snare); box-shadow: 0 0 8px var(--cell-active-snare); }
.cell.active.clap { background-color: var(--cell-active-clap); box-shadow: 0 0 8px var(--cell-active-clap); }
.cell.active.hihat { background-color: var(--cell-active-hihat); box-shadow: 0 0 8px var(--cell-active-hihat); }
.cell.active.tom { background-color: var(--cell-active-tom); box-shadow: 0 0 8px var(--cell-active-tom); }
.cell.active.crash { background-color: var(--cell-active-crash); box-shadow: 0 0 8px var(--cell-active-crash); }
.cell.active.ride { background-color: var(--cell-active-ride); box-shadow: 0 0 8px var(--cell-active-ride); }
.cell.active.perc { background-color: var(--cell-active-perc); box-shadow: 0 0 8px var(--cell-active-perc); }
.cell.active.note { background-color: var(--cell-active-note); box-shadow: 0 0 8px var(--cell-active-note); }
.cell.highlight {
/* Use outline instead of scale to prevent layout shifts */
outline: 2px solid var(--cell-highlight);
outline-offset: -2px;
background-color: var(--cell-highlight) !important; /* Ensure highlight overrides */
/* transform: scale(1.05); */ /* Removed scale to avoid minor layout shifts */
box-shadow: inset 0 0 5px rgba(255, 255, 255, 0.3);
}
/* Controls Styling */
.controls-row {
display: flex;
flex-direction: row;
gap: 15px;
width: 100%;
flex-wrap: wrap;
justify-content: center;
}
.control-group {
background-color: var(--control-background);
padding: 15px;
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 150px;
}
.control-group label,
.control-group span {
font-size: 14px;
display: block;
margin-bottom: 3px;
text-align: center;
}
.control-group span.value-display {
font-weight: bold;
min-height: 1.2em; /* Prevent layout jump when value appears */
}
.control-group input[type="range"] {
width: 100%;
cursor: pointer;
}
.control-group select {
width: 100%;
padding: 8px; /* Slightly more padding */
border-radius: 4px;
background-color: var(--cell-color);
color: var(--text-color);
border: 1px solid var(--button-color);
font-family: var(--font-family);
font-size: 14px;
}
.playback-controls, .drum-selector {
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
background-color: var(--control-background);
padding: 10px 15px;
border-radius: 8px;
flex-wrap: wrap; /* Allow buttons to wrap */
width: 100%; /* Take full width within grid-section */
}
.playback-controls label {
margin-right: 5px;
flex-shrink: 0; /* Prevent label from shrinking */
}
.playback-controls .tempo-value {
min-width: 35px;
text-align: right;
font-weight: bold;
flex-shrink: 0;
}
.playback-controls input[type="range"] {
flex-grow: 1; /* Allow slider to take available space */
min-width: 80px; /* Minimum width for the slider */
}
/* Button Styling */
button {
padding: 8px 12px; /* Adjust padding */
font-size: 14px;
font-weight: bold;
font-family: var(--font-family);
background-color: var(--button-color);
color: var(--text-color);
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
flex-shrink: 0; /* Prevent buttons shrinking too much */
}
button:hover {
background-color: var(--button-hover);
}
#play-stop-button.active {
background-color: var(--button-active);
color: var(--background-color);
}
.drum-selector button.active {
background-color: var(--button-drum-active);
box-shadow: 0 0 8px var(--button-drum-active);
}
/* Demo Button Style */
#demo-button {
background-color: var(--button-demo-color);
}
#demo-button:hover {
background-color: var(--button-demo-hover);
}
/* Record Button Style */
#record-button {
background-color: var(--button-record-color);
}
#record-button:hover {
background-color: var(--button-record-active);
}
#record-button.recording {
background-color: var(--button-record-active);
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
/* Vertical Volume Slider */
.volume-control {
display: flex;
/* Default to vertical */
flex-direction: column;
align-items: center;
gap: 5px;
}
.volume-control input[type="range"] {
/* Vertical styles */
writing-mode: bt-lr; /* IE/Edge */
-webkit-appearance: slider-vertical; /* WebKit */
width: 8px;
height: 120px;
padding: 0 5px;
background: var(--cell-color);
border-radius: 5px;
cursor: pointer;
}
.volume-control input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 18px; height: 18px; background: var(--button-hover); border-radius: 50%; cursor: pointer;
}
.volume-control input[type="range"]::-moz-range-thumb {
width: 18px; height: 18px; background: var(--button-hover); border-radius: 50%; cursor: pointer; border: none;
}
/* Grid Size Controls */
.grid-size-controls {
display: flex;
flex-direction: column;
gap: 10px;
}
.grid-size-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.grid-size-controls button {
padding: 6px 10px;
font-size: 12px;
min-width: 30px;
}
/* Message Box Styling */
#message-box {
position: fixed;
top: 10px; /* Closer to top */
left: 50%;
transform: translateX(-50%);
background-color: var(--button-active);
color: var(--background-color);
padding: 8px 15px; /* Adjusted padding */
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
z-index: 1000;
display: none;
font-weight: bold;
font-size: 14px;
text-align: center;
max-width: 90%; /* Prevent it being too wide */
}
/* --- Responsive Styles --- */
/* Medium Screens (Tablets) */
@media (max-width: 768px) {
body {
align-items: flex-start; /* Align to top for scroll */
padding: 15px;
}
.looper-container {
flex-direction: column; /* Stack sections vertically */
align-items: center; /* Center items in the column */
gap: 25px;
padding: 15px;
}
.grid-section {
width: 100%; /* Allow grid section to take full width */
max-width: 450px; /* Limit max width */
}
.controls-row {
width: 100%; /* Controls take full width */
max-width: 450px; /* Limit max width */
flex-direction: row; /* Arrange controls horizontally */
flex-wrap: wrap; /* Allow control groups to wrap */
justify-content: center; /* Center control groups */
}
.control-group {
flex-grow: 1; /* Allow groups to grow */
min-width: 150px; /* Minimum width for control groups */
}
.volume-control {
height: auto; /* Reset height */
}
}
/* Small Screens (Phones) */
@media (max-width: 480px) {
:root {
/* Smaller cells for phones */
--cell-size: 30px;
--grid-gap: 3px;
}
body {
padding: 5px; /* Reduce body padding */
}
.looper-container {
padding: 10px;
gap: 15px;
}
.grid {
padding: 5px; /* Reduce grid padding */
}
.controls-row {
flex-direction: column; /* Stack control groups vertically again */
align-items: center; /* Center groups */
width: 100%;
max-width: 300px; /* Further limit width */
}
.control-group {
width: 100%; /* Make control groups full width */
min-width: unset; /* Remove min-width */
}
.playback-controls, .drum-selector {
padding: 8px 10px;
gap: 8px;
}
button {
padding: 6px 10px;
font-size: 13px;
}
.control-group label, .control-group span {
font-size: 13px;
}
.volume-control {
/* Make volume horizontal on small screens */
flex-direction: row;
width: 100%;
justify-content: center;
align-items: center; /* Center label/value vertically */
}
.volume-control input[type="range"] {
/* Reset vertical styles */
-webkit-appearance: none;
appearance: none;
writing-mode: horizontal-tb;
/* Apply horizontal styles */
width: 60%; /* Adjust width as needed */
height: 8px;
max-width: 150px;
padding: 0; /* Remove vertical padding */
}
.volume-control input[type="range"]::-webkit-slider-thumb {
/* Ensure thumb styles are appropriate for horizontal */
width: 16px; /* Slightly smaller thumb */
height: 16px;
}
.volume-control input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
}
#message-box {
top: 5px;
font-size: 12px;
padding: 6px 10px;
}
}
/* Very Small Screens */
@media (max-width: 360px) {
:root {
--cell-size: 28px; /* Even smaller cells */
--grid-gap: 2px;
}
button {
padding: 5px 8px;
font-size: 12px;
}
.playback-controls, .drum-selector {
gap: 5px;
}
}
</style>
</head>
<body>
<div id="message-box">Click 'Play' or grid to start audio</div>
<div class="looper-container">
<div class="grid-section">
<div class="grid-container">
<div class="grid" id="grid-container"></div>
</div>
<div class="drum-selector" id="drum-selector">
<span>Drum Tool:</span>
<button data-sound="kick">Kick</button>
<button data-sound="snare">Snare</button>
<button data-sound="clap">Clap</button>
<button data-sound="hihat">HiHat</button>
<button data-sound="tom">Tom</button>
<button data-sound="crash">Crash</button>
<button data-sound="ride">Ride</button>
<button data-sound="perc">Perc</button>
</div>
<div class="playback-controls">
<button id="play-stop-button">Play</button>
<button id="demo-button">Demo</button>
<button id="record-button">Record</button>
<label for="tempo">Tempo:</label>
<input type="range" id="tempo" name="tempo" min="40" max="240" value="120">
<span class="tempo-value" id="tempo-value">120</span> BPM
</div>
<div class="controls-row">
<div class="control-group volume-control">
<label for="volume">Volume</label>
<input type="range" id="volume" name="volume" min="-40" max="0" value="-6" step="1">
<span class="value-display" id="volume-value">-6 dB</span>
</div>
<div class="control-group">
<label for="synth-type">Note Synth</label>
<select id="synth-type" name="synth-type">
<option value="MembraneSynth">Membrane</option>
<option value="Synth">Synth</option>
<option value="AMSynth">AM</option>
<option value="FMSynth">FM</option>
<option value="MonoSynth">MonoSynth</option>
</select>
<label for="decay">Decay/Release</label>
<input type="range" id="decay" name="decay" min="0.01" max="1.5" value="0.2" step="0.01">
<span class="value-display" id="decay-value">0.20 s</span>
<label for="reverb">Reverb Wet</label>
<input type="range" id="reverb" name="reverb" min="0" max="1" value="0.1" step="0.05">
<span class="value-display" id="reverb-value">0.10</span>
</div>
<div class="control-group grid-size-controls">
<div class="grid-size-row">
<span>Columns:</span>
<div>
<button id="decrease-cols">-</button>
<span id="cols-value">8</span>
<button id="increase-cols">+</button>
</div>
</div>
<div class="grid-size-row">
<span>Rows:</span>
<div>
<button id="decrease-rows">-</button>
<span id="rows-value">8</span>
<button id="increase-rows">+</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// --- DOM Elements ---
const gridContainer = document.getElementById('grid-container');
const playStopButton = document.getElementById('play-stop-button');
const demoButton = document.getElementById('demo-button');
const recordButton = document.getElementById('record-button');
const tempoSlider = document.getElementById('tempo');
const tempoValueDisplay = document.getElementById('tempo-value');
const volumeSlider = document.getElementById('volume');
const volumeValueDisplay = document.getElementById('volume-value');
const messageBox = document.getElementById('message-box');
const synthTypeSelector = document.getElementById('synth-type');
const decaySlider = document.getElementById('decay');
const decayValueDisplay = document.getElementById('decay-value');
const reverbSlider = document.getElementById('reverb');
const reverbValueDisplay = document.getElementById('reverb-value');
const drumSelectorContainer = document.getElementById('drum-selector');
const drumButtons = drumSelectorContainer.querySelectorAll('button');
const decreaseColsBtn = document.getElementById('decrease-cols');
const increaseColsBtn = document.getElementById('increase-cols');
const colsValueDisplay = document.getElementById('cols-value');
const decreaseRowsBtn = document.getElementById('decrease-rows');
const increaseRowsBtn = document.getElementById('increase-rows');
const rowsValueDisplay = document.getElementById('rows-value');
// --- Constants ---
const MIN_COLS = 4;
const MAX_COLS = Infinity; // Removed column limit
const MIN_ROWS = 4;
const MAX_ROWS = 16;
const NOTES = ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5", "D5", "E5", "F5", "G5", "A5", "B5"];
const DRUM_SOUNDS = ['kick', 'snare', 'clap', 'hihat', 'tom', 'crash', 'ride', 'perc'];
// --- State ---
let numCols = 8;
let numRows = 8;
let gridState = Array(numRows).fill(null).map((_, r) =>
Array(numCols).fill(r === 0 ? null : false)
);
let synths = [];
let percussion = {};
let reverbEffect;
let masterVolume;
let sequence;
let currentColumn = 0;
let isPlaying = false;
let audioInitialized = false;
let audioContextStarted = false;
let currentSynthType = 'MembraneSynth';
let currentDecay = 0.2;
let selectedDrumSound = DRUM_SOUNDS[0];
let currentDemoIndex = -1;
let recorder;
let isRecording = false;
let audioStream;
// --- Demo Patterns ---
const demoPatterns = [
[['kick', null, 'snare', null, 'kick', null, 'snare', null],[true, false, false, false, true, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false]],
[['kick', 'hihat', 'kick', 'hihat', 'kick', 'hihat', 'kick', 'hihat'],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[true, false, false, true, false, false, true, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false]],
[['kick', 'hihat', 'snare', 'hihat', 'kick', 'hihat', 'snare', 'hihat'],[true, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[true, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[true, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false]],
[['kick', null, null, 'clap', 'kick', 'kick', null, 'clap'],[true, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, true, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, true, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, true, false]],
[['kick', 'hihat', 'snare', 'hihat', 'kick', 'hihat', 'snare', 'hihat'],[true, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, true, false, false, false],[false, false, false, false, false, false, false, false],[false, false, false, false, false, false, false, false]]
];
// --- Tone.js Setup ---
function setupNoteSynths() {
console.log("Setting up note synths...");
synths.forEach(synth => synth?.dispose());
synths = [];
const synthOptions = {
envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: currentDecay },
...(currentSynthType === 'MonoSynth' && { volume: -8 }),
...(currentSynthType === 'FMSynth' && { harmonicity: 3, modulationIndex: 10 }),
...(currentSynthType === 'AMSynth' && { harmonicity: 3 }),
...(currentSynthType === 'MembraneSynth' && { pitchDecay: 0.01, octaves: 4 })
};
for (let i = 0; i < numRows - 1; i++) {
if (Tone[currentSynthType]) {
const synth = new Tone[currentSynthType](synthOptions);
// Always connect to reverbEffect, which connects to masterVolume
if (reverbEffect) synth.connect(reverbEffect);
else console.error("Reverb effect not ready for synth connection");
synths.push(synth);
} else {
console.error(`Invalid synth type: ${currentSynthType}`);
synths.push(null);
}
}
console.log(`Created ${synths.length} synths of type ${currentSynthType}.`);
}
/**
* Initializes the full audio setup (synths, effects, sequence) AFTER Tone.start() is successful.
*/
async function completeAudioSetup() {
if (audioInitialized) return; // Prevent running setup twice
console.log("Completing audio setup...");
try {
// Create master volume control and connect to destination (speakers)
masterVolume = new Tone.Volume(parseFloat(volumeSlider.value)).toDestination();
console.log("Master volume created.");
// Create reverb effect and connect it to the master volume
reverbEffect = new Tone.Reverb({
wet: parseFloat(reverbSlider.value), // Initial wetness from slider
decay: 1.5 // Fixed decay time for the reverb
}).connect(masterVolume);
// Wait for the reverb impulse response to load
await reverbEffect.ready;
console.log("Reverb effect ready.");
// Create percussion synth instances and connect them to the reverb
percussion = {
kick: new Tone.MembraneSynth({ pitchDecay: 0.05, octaves: 10, oscillator: { type: "sine" }, envelope: { attack: 0.001, decay: 0.4, sustain: 0.01, release: 0.8, attackCurve: "exponential" } }).connect(reverbEffect),
snare: new Tone.NoiseSynth({ noise: { type: 'white' }, envelope: { attack: 0.005, decay: 0.15, sustain: 0, release: 0.1 } }).connect(reverbEffect),
clap: new Tone.NoiseSynth({ noise: { type: 'pink' }, envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.15, attackCurve: 'exponential' } }).connect(reverbEffect),
hihat: new Tone.MetalSynth({ frequency: 300, envelope: { attack: 0.001, decay: 0.05, release: 0.05 }, harmonicity: 5.1, modulationIndex: 32, resonance: 4000, octaves: 1.5 }).connect(reverbEffect),
tom: new Tone.MembraneSynth({ pitchDecay: 0.1, octaves: 8, oscillator: { type: "sine" }, envelope: { attack: 0.001, decay: 0.5, sustain: 0.01, release: 0.5 } }).connect(reverbEffect),
crash: new Tone.MetalSynth({ frequency: 400, envelope: { attack: 0.001, decay: 0.5, release: 0.5 }, harmonicity: 8, modulationIndex: 40, resonance: 3000 }).connect(reverbEffect),
ride: new Tone.MetalSynth({ frequency: 500, envelope: { attack: 0.001, decay: 0.3, release: 0.3 }, harmonicity: 5, modulationIndex: 20, resonance: 2000 }).connect(reverbEffect),
perc: new Tone.NoiseSynth({ noise: { type: 'white' }, envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.2 } }).connect(reverbEffect)
};
console.log("Percussion synths created.");
// Get initial synth settings from controls
currentSynthType = synthTypeSelector.value;
currentDecay = parseFloat(decaySlider.value);
// Create the note synths based on initial settings
setupNoteSynths();
// Create the sequence (will be updated when grid size changes)
createSequence();
// Set the initial tempo from the slider
Tone.Transport.bpm.value = parseInt(tempoSlider.value);
audioInitialized = true; // Mark full setup as complete
hideMessage(); // Hide any initialization messages
setActiveDrumButton(selectedDrumSound); // Highlight the default drum button
console.log("Audio setup fully complete.");
} catch (err) {
console.error("Error during completeAudioSetup:", err);
showMessage("Failed to set up audio components.", true);
audioInitialized = false; // Ensure flag is false on failure
}
}
function createSequence() {
// Dispose of the old sequence if it exists
if (sequence) {
sequence.dispose();
}
// Define the steps for the sequence (0 to numCols - 1)
const steps = Array.from(Array(numCols).keys());
// Create the main Tone.js Sequence
sequence = new Tone.Sequence((time, col) => {
currentColumn = col; // Update the current column index
// Schedule visual highlighting using Tone.Draw for accurate timing
Tone.Draw.schedule(() => {
highlightColumn(col);
}, time);
// Iterate through each row for the current column (step)
for (let row = 0; row < numRows; row++) {
const cellState = gridState[row][col]; // Get state (sound name or boolean)
if (cellState) { // If the cell is active
if (row === 0) { // Percussion Row
const soundName = cellState;
if (percussion[soundName]?.triggerAttackRelease) {
try {
if (soundName === 'snare' || soundName === 'clap' || soundName === 'perc') {
percussion[soundName].triggerAttackRelease("8n", time);
} else if (soundName === 'hihat' || soundName === 'crash' || soundName === 'ride') {
percussion[soundName].triggerAttackRelease("C1", "16n", time);
} else {
percussion[soundName].triggerAttackRelease("C1", "8n", time);
}
} catch (e) { console.error(`Error triggering percussion ${soundName} at col ${col}:`, e); }
}
} else { // Note Rows (1 to numRows-1)
const synthIndex = row - 1;
if (synths[synthIndex]?.triggerAttackRelease) {
try {
const note = NOTES[synthIndex % NOTES.length];
synths[synthIndex].triggerAttackRelease(note, "8n", time);
} catch (e) { console.error(`Error triggering synth ${synthIndex} (${currentSynthType}) at col ${col}:`, e); }
}
}
}
}
}, steps, "8n"); // Sequence runs at "8n" (eighth note) interval
console.log("Sequence created with", numCols, "steps");
}
/**
* Attempts to start the Tone.js AudioContext. Must be called from a user gesture.
*/
async function startAudioContext() {
if (audioContextStarted) return true; // Already started
console.log("Attempting to start Tone.js Audio Context...");
try {
await Tone.start();
// Check context state after starting
if (Tone.context.state === 'running') {
audioContextStarted = true;
console.log("Tone.js Audio Context started successfully. State:", Tone.context.state);
// Now complete the rest of the setup
await completeAudioSetup();
return true;
} else {
console.warn("Tone.start() completed but context state is:", Tone.context.state);
showMessage("Audio context did not start properly.", true);
return false;
}
} catch (err) {
console.error("Tone.start() failed:", err);
showMessage("Could not start audio. User interaction might be required.", true);
audioContextStarted = false;
return false;
}
}
// --- Recording Functions ---
async function startRecording() {
if (!audioInitialized) {
showMessage("Please initialize audio first by clicking Play or a grid cell");
return;
}
try {
// Get the audio stream from the Web Audio destination
const dest = Tone.context.createMediaStreamDestination();
Tone.Destination.connect(dest);
// Create a recorder
recorder = new RecordRTC(dest.stream, {
type: 'audio',
mimeType: 'audio/wav',
recorderType: RecordRTC.StereoAudioRecorder,
desiredSampRate: 44100,
numberOfAudioChannels: 2
});
// Start recording
recorder.startRecording();
isRecording = true;
recordButton.classList.add('recording');
showMessage("Recording started...");
console.log("Recording started");
} catch (err) {
console.error("Recording error:", err);
showMessage("Recording failed to start", true);
isRecording = false;
recordButton.classList.remove('recording');
}
}
async function stopRecording() {
if (!isRecording || !recorder) return;
return new Promise((resolve) => {
recorder.stopRecording(() => {
isRecording = false;
recordButton.classList.remove('recording');
// Get the recorded blob
const blob = recorder.getBlob();
// Create download link
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `looper-recording-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.wav`;
document.body.appendChild(a);
a.click();
// Clean up
setTimeout(() => {
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}, 100);
showMessage("Recording saved as WAV file");
console.log("Recording stopped and saved");
// Dispose the recorder
recorder.destroy();
recorder = null;
resolve();
});
});
}
async function toggleRecording() {
if (isRecording) {
await stopRecording();
} else {
await startRecording();
}
}
// --- Grid Creation & Update ---
function createGrid() {
gridContainer.innerHTML = '';
// Update CSS variables for grid size
gridContainer.style.setProperty('--num-cols', numCols);
gridContainer.style.setProperty('--num-rows', numRows);
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const cell = document.createElement('div');
cell.classList.add('cell');
cell.dataset.row = row;
cell.dataset.col = col;
cell.dataset.type = (row === 0) ? 'percussion' : 'note';
// Use touchstart primarily for mobile, click as fallback
cell.addEventListener('touchstart', handleCellInteraction, { passive: true });
cell.addEventListener('click', handleCellInteraction);
gridContainer.appendChild(cell);
}
}
updateGridVisuals();
}
function updateGridVisuals() {
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const cell = gridContainer.querySelector(`.cell[data-row="${row}"][data-col="${col}"]`);
if (!cell) continue;
const cellState = gridState[row][col];
const isActive = !!cellState;
cell.classList.remove('active', 'note', ...DRUM_SOUNDS);
if (isActive) {
cell.classList.add('active');
if (row === 0) {
if (typeof cellState === 'string') cell.classList.add(cellState);
} else {
cell.classList.add('note');
}
}
}
}
}
function resizeGrid(newCols, newRows) {
// Create a new grid state with the new dimensions
const newGridState = Array(newRows).fill(null).map((_, row) =>
Array(newCols).fill(null).map((_, col) => {
// If we're within the bounds of the old grid, copy the value
if (row < numRows && col < numCols) {
return gridState[row][col];
}
// For new rows/columns, set default values
return row === 0 ? null : false;
})
);
// Update the grid dimensions
numCols = newCols;
numCols = Math.max(MIN_COLS, numCols); // Removed upper limit
numRows = newRows;
numRows = Math.max(MIN_ROWS, Math.min(MAX_ROWS, numRows));
// Update the display
colsValueDisplay.textContent = numCols;
rowsValueDisplay.textContent = numRows;
// Update the grid state
gridState = newGridState;
// Recreate the grid UI
createGrid();
// If audio is initialized, recreate the sequence
if (audioInitialized) {
createSequence();
if (isPlaying) {
Tone.Transport.stop();
sequence.start(0);
Tone.Transport.start();
}
}
}
// --- Event Handlers ---
/**
* Unified handler for cell clicks and touches.
*/
async function handleCellInteraction(event) {
// Ensure audio context is started on the first interaction
if (!audioContextStarted) {
showMessage("Initializing Audio...");
const success = await startAudioContext();
if (!success) return; // Stop if context didn't start
}
// If full setup isn't complete yet, wait or show message
if (!audioInitialized) {
showMessage("Audio setup in progress...");
console.log("Cell interaction ignored: audio not fully initialized yet.");
return;
}
// Proceed with cell logic only if audio is fully ready
const cell = event.currentTarget;
const row = parseInt(cell.dataset.row);
const col = parseInt(cell.dataset.col);
if (row === 0) { // Percussion Row
const currentSoundInCell = gridState[row][col];
if (currentSoundInCell === selectedDrumSound) {
gridState[row][col] = null;
cell.classList.remove('active', selectedDrumSound);
} else {
if (currentSoundInCell) cell.classList.remove(currentSoundInCell);
gridState[row][col] = selectedDrumSound;
cell.classList.add('active', selectedDrumSound);
}
} else { // Note Rows
gridState[row][col] = !gridState[row][col];
cell.classList.toggle('active', gridState[row][col]);
cell.classList.toggle('note', gridState[row][col]);
}
}
/**
* Handles clicks/touches on the Play/Stop button.
*/
async function handlePlayStopInteraction() {
// Ensure audio context is started first
if (!audioContextStarted) {
showMessage("Initializing Audio...");
const success = await startAudioContext();
if (!success) return; // Stop if context didn't start
}
// Wait until full setup is complete before toggling playback
if (!audioInitialized) {
showMessage("Audio setup in progress...");
console.log("Play/Stop ignored: audio not fully initialized yet.");
return;
}
// If audio is ready, toggle playback
togglePlayback();
}
function togglePlayback() {
if (!audioInitialized || !sequence) {
console.warn("Toggle playback called before sequence is ready.");
return;
}
// Check AudioContext state before trying to play
if (Tone.context.state === 'suspended') {
console.log("Audio context is suspended, attempting to resume...");
Tone.context.resume().then(() => {
console.log("Context resumed.");
// Try toggling again after resume
togglePlaybackInternal();
}).catch(err => {
console.error("Failed to resume audio context:", err);
showMessage("Could not resume audio.", true);
});
} else if (Tone.context.state === 'running') {
togglePlaybackInternal();
} else {
console.error("Cannot toggle playback, Tone.context state is:", Tone.context.state);
showMessage("Audio context issue. State: " + Tone.context.state, true);
}
}
/** Internal playback toggle logic, called when context is running */
function togglePlaybackInternal() {
if (isPlaying) {
console.log("Stopping transport...");
Tone.Transport.stop();
sequence.stop();
playStopButton.textContent = 'Play';
playStopButton.classList.remove('active');
clearHighlight();
isPlaying = false;
} else {
console.log("Starting transport...");
Tone.Transport.start();
sequence.start(0);
playStopButton.textContent = 'Stop';
playStopButton.classList.add('active');
isPlaying = true;
}
}
/** Handles clicks/touches on the Demo button. */
async function handleDemoInteraction() {
// Ensure audio context is started first
if (!audioContextStarted) {
showMessage("Please click Play/Grid first to initialize audio.");
const success = await startAudioContext(); // Optionally try to init here too
if (!success) return;
}
if (!audioInitialized) {
showMessage("Audio setup in progress...");
return;
}
currentDemoIndex = (currentDemoIndex + 1) % demoPatterns.length;
const patternToLoad = demoPatterns[currentDemoIndex];
// Resize grid to match demo pattern if needed
const demoCols = patternToLoad[0].length;
const demoRows = patternToLoad.length;
if (demoCols !== numCols || demoRows !== numRows) {
resizeGrid(demoCols, demoRows);
}
// Copy the pattern to our grid state
gridState = patternToLoad.map(row => [...row]);
updateGridVisuals();
showMessage(`Loaded Demo Pattern ${currentDemoIndex + 1}`);
console.log(`Loaded Demo Pattern ${currentDemoIndex + 1}`);
}
function handleTempoChange(event) {
const newTempo = parseInt(event.target.value);
tempoValueDisplay.textContent = newTempo;
if (audioInitialized) Tone.Transport.bpm.value = newTempo;
}
function handleVolumeChange(event) {
const newVolume = parseFloat(event.target.value);
volumeValueDisplay.textContent = `${newVolume} dB`;
if (audioInitialized && masterVolume) {
// Clamp the volume to prevent distortion
const clampedVolume = Math.max(-40, Math.min(0, newVolume));
masterVolume.volume.value = clampedVolume;
// Update the display with the clamped value
volumeValueDisplay.textContent = `${clampedVolume} dB`;
}
}
function handleSynthTypeChange(event) {
currentSynthType = event.target.value;
console.log("Synth type changed to:", currentSynthType);
if (audioInitialized) {
const wasPlaying = isPlaying;
if (wasPlaying) togglePlayback(); // Stop playback before changing synths
console.log("Setting up new synths due to type change...");
setupNoteSynths(); // Recreate synths
// No need to resume playback automatically, user can press play again
} else {
console.log("Synth type changed, but audio not initialized yet. Will apply on init.");
}
}
function handleDecayChange(event) {
currentDecay = parseFloat(event.target.value);
decayValueDisplay.textContent = `${currentDecay.toFixed(2)} s`;
if (audioInitialized) {
synths.forEach(synth => {
if (synth?.envelope) {
if (typeof synth.envelope.release !== 'undefined') synth.envelope.release = currentDecay;
if (synth.filterEnvelope && typeof synth.filterEnvelope.release !== 'undefined') synth.filterEnvelope.release = currentDecay * 1.5;
}
});
}
}
function handleReverbChange(event) {
const newWetness = parseFloat(event.target.value);
reverbValueDisplay.textContent = newWetness.toFixed(2);
if (audioInitialized && reverbEffect) reverbEffect.wet.value = newWetness;
}
function handleDrumToolSelect(event) {
const button = event.target.closest('button');
if (button?.dataset.sound) {
selectedDrumSound = button.dataset.sound;
setActiveDrumButton(selectedDrumSound);
console.log("Selected drum tool:", selectedDrumSound);
}
}
function setActiveDrumButton(soundName) {
drumButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.sound === soundName));
}
function handleGridSizeChange(colsDelta, rowsDelta) {
const newCols = Math.max(MIN_COLS, numCols + colsDelta); // Removed upper limit
const newRows = Math.max(MIN_ROWS, Math.min(MAX_ROWS, numRows + rowsDelta));
if (newCols !== numCols || newRows !== numRows) {
resizeGrid(newCols, newRows);
}
}
// --- Visual Updates During Playback ---
function highlightColumn(colIndex) {
clearHighlight();
const cells = gridContainer.querySelectorAll(`.cell[data-col="${colIndex}"]`);
cells.forEach(cell => cell.classList.add('highlight'));
}
function clearHighlight() {
const highlightedCells = gridContainer.querySelectorAll('.cell.highlight');
highlightedCells.forEach(cell => cell.classList.remove('highlight'));
}
// --- Message Box Functions ---
let messageTimeoutId = null; // Store timeout ID for message box
function showMessage(text, isError = false) {
messageBox.textContent = text;
messageBox.style.backgroundColor = isError ? 'var(--cell-active-kick)' : 'var(--button-active)';
messageBox.style.color = isError ? 'var(--text-color)' : 'var(--background-color)';
messageBox.style.display = 'block';
// Clear any existing timeout
if (messageTimeoutId) clearTimeout(messageTimeoutId);
// Auto-hide message
messageTimeoutId = setTimeout(hideMessage, 3500);
}
function hideMessage() {
messageBox.style.display = 'none';
if (messageTimeoutId) {
clearTimeout(messageTimeoutId);
messageTimeoutId = null;
}
}
// --- Initialization ---
// Create the initial grid
createGrid();
// Add event listeners using the interaction handlers
playStopButton.addEventListener('touchstart', handlePlayStopInteraction, { passive: false });
playStopButton.addEventListener('click', handlePlayStopInteraction);
demoButton.addEventListener('touchstart', handleDemoInteraction, { passive: true });
demoButton.addEventListener('click', handleDemoInteraction);
recordButton.addEventListener('click', toggleRecording);
// Other control listeners
tempoSlider.addEventListener('input', handleTempoChange);
volumeSlider.addEventListener('input', handleVolumeChange);
synthTypeSelector.addEventListener('change', handleSynthTypeChange);
decaySlider.addEventListener('input', handleDecayChange);
reverbSlider.addEventListener('input', handleReverbChange);
drumSelectorContainer.addEventListener('click', handleDrumToolSelect);
// Grid size controls
decreaseColsBtn.addEventListener('click', () => handleGridSizeChange(-1, 0));
increaseColsBtn.addEventListener('click', () => handleGridSizeChange(1, 0));
decreaseRowsBtn.addEventListener('click', () => handleGridSizeChange(0, -1));
increaseRowsBtn.addEventListener('click', () => handleGridSizeChange(0, 1));
// Initial message
showMessage("Click 'Play' or grid to start audio");
// Ensure audio stops when window is closed to prevent audio glitches
window.addEventListener('beforeunload', () => {
if (Tone.context.state !== 'closed') {
Tone.context.close();
}
if (isRecording) {
stopRecording();
}
});
</script>
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - <a href="https://enzostvs-deepsite.hf.space?remix=SignorNessuno/loopstation" style="color: #fff;text-decoration: underline;" target="_blank" >🧬 Remix</a></p></body>
</html>