Spaces:
Running
Running
Download index.html from SignorNessuno/loopstation: direct link, hf CLI and curl.
- Browser
- Download file 55.1 kB
-
https://huggingface.co/spaces/SignorNessuno/loopstation/resolve/main/index.html
- Command line
-
hf download hf://spaces/SignorNessuno/loopstation/index.html
-
curl -L -o index.html https://huggingface.co/spaces/SignorNessuno/loopstation/resolve/main/index.html
55.1 kB
| <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) ; /* 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> |