|
| 1 | +/* |
| 2 | + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) |
| 3 | + * |
| 4 | + * SPDX-License-Identifier: MPL-2.0 |
| 5 | + */ |
| 6 | + |
| 7 | +(function(){ |
| 8 | + const socket = io({ transports: ['websocket'] }); |
| 9 | + |
| 10 | + // Logger utility |
| 11 | + const log = { |
| 12 | + info: (msg, ...args) => console.log(`[MusicComposer] ${msg}`, ...args), |
| 13 | + debug: (msg, ...args) => console.debug(`[MusicComposer] ${msg}`, ...args), |
| 14 | + warn: (msg, ...args) => console.warn(`[MusicComposer] ${msg}`, ...args), |
| 15 | + error: (msg, ...args) => console.error(`[MusicComposer] ${msg}`, ...args) |
| 16 | + }; |
| 17 | + |
| 18 | + // Configuration |
| 19 | + const GRID_STEPS = 16; |
| 20 | + const NOTES = ['B4', 'A#4', 'A4', 'G#4', 'G4', 'F#4', 'F4', 'E4', 'D#4', 'D4', 'C#4', 'C4']; |
| 21 | + |
| 22 | + // State |
| 23 | + let grid = null; // {noteIndex: {stepIndex: true/false}} - null until server sends state |
| 24 | + let isPlaying = false; |
| 25 | + let currentStep = 0; |
| 26 | + let bpm = 120; |
| 27 | + let playInterval = null; |
| 28 | + let effects = { |
| 29 | + reverb: 0, |
| 30 | + chorus: 0, |
| 31 | + tremolo: 0, |
| 32 | + vibrato: 0, |
| 33 | + overdrive: 0 |
| 34 | + }; |
| 35 | + |
| 36 | + // DOM elements |
| 37 | + const playBtn = document.getElementById('play-btn'); |
| 38 | + const stopBtn = document.getElementById('stop-btn'); |
| 39 | + const bpmInput = document.getElementById('bpm-input'); |
| 40 | + const resetBpmBtn = document.getElementById('reset-bpm'); |
| 41 | + const undoBtn = document.getElementById('undo-btn'); |
| 42 | + const redoBtn = document.getElementById('redo-btn'); |
| 43 | + const clearBtn = document.getElementById('clear-btn'); |
| 44 | + const exportBtn = document.getElementById('export-btn'); |
| 45 | + const sequencerGrid = document.getElementById('sequencer-grid'); |
| 46 | + const volumeSlider = document.getElementById('volume-slider'); |
| 47 | + const waveButtons = document.querySelectorAll('.wave-btn'); |
| 48 | + const knobs = document.querySelectorAll('.knob'); |
| 49 | + |
| 50 | + // Initialize |
| 51 | + socket.on('connect', () => { |
| 52 | + log.info('Connected to server'); |
| 53 | + socket.emit('composer:get_state', {}); |
| 54 | + }); |
| 55 | + |
| 56 | + // Socket events |
| 57 | + socket.on('composer:state', (data) => { |
| 58 | + log.info('Received state from server:', JSON.stringify(data)); |
| 59 | + if (data.grid) { |
| 60 | + const oldGrid = JSON.stringify(grid); |
| 61 | + grid = data.grid; |
| 62 | + const newGrid = JSON.stringify(grid); |
| 63 | + if (oldGrid !== newGrid) { |
| 64 | + log.info('Grid changed from', oldGrid, 'to', newGrid); |
| 65 | + } |
| 66 | + } else { |
| 67 | + // Initialize empty grid if server sends nothing |
| 68 | + grid = {}; |
| 69 | + log.info('Grid initialized as empty'); |
| 70 | + } |
| 71 | + if (data.bpm) { |
| 72 | + bpm = data.bpm; |
| 73 | + bpmInput.value = bpm; |
| 74 | + log.info('BPM updated:', bpm); |
| 75 | + } |
| 76 | + if (data.effects) { |
| 77 | + effects = data.effects; |
| 78 | + log.info('Effects updated:', effects); |
| 79 | + } |
| 80 | + renderGrid(); |
| 81 | + updateEffectsKnobs(); |
| 82 | + }); |
| 83 | + |
| 84 | + socket.on('composer:step_playing', (data) => { |
| 85 | + log.debug('Step playing:', data.step); |
| 86 | + highlightStep(data.step); |
| 87 | + }); |
| 88 | + |
| 89 | + // Build grid |
| 90 | + function buildGrid() { |
| 91 | + sequencerGrid.innerHTML = ''; |
| 92 | + |
| 93 | + // Top-left corner (empty) |
| 94 | + const corner = document.createElement('div'); |
| 95 | + sequencerGrid.appendChild(corner); |
| 96 | + |
| 97 | + // Column labels (step numbers) |
| 98 | + for (let step = 0; step < GRID_STEPS; step++) { |
| 99 | + const label = document.createElement('div'); |
| 100 | + label.className = 'grid-col-label'; |
| 101 | + label.textContent = step + 1; |
| 102 | + sequencerGrid.appendChild(label); |
| 103 | + } |
| 104 | + |
| 105 | + // Grid rows |
| 106 | + NOTES.forEach((note, noteIndex) => { |
| 107 | + // Row label (note name) |
| 108 | + const rowLabel = document.createElement('div'); |
| 109 | + rowLabel.className = 'grid-row-label'; |
| 110 | + rowLabel.textContent = note; |
| 111 | + sequencerGrid.appendChild(rowLabel); |
| 112 | + |
| 113 | + // Grid cells |
| 114 | + for (let step = 0; step < GRID_STEPS; step++) { |
| 115 | + const cell = document.createElement('div'); |
| 116 | + cell.className = 'grid-cell'; |
| 117 | + cell.dataset.note = noteIndex; |
| 118 | + cell.dataset.step = step; |
| 119 | + |
| 120 | + // Add beat separator every 4 steps |
| 121 | + if ((step + 1) % 4 === 0 && step < GRID_STEPS - 1) { |
| 122 | + cell.classList.add('beat-separator'); |
| 123 | + } |
| 124 | + |
| 125 | + cell.addEventListener('click', () => toggleCell(noteIndex, step)); |
| 126 | + sequencerGrid.appendChild(cell); |
| 127 | + } |
| 128 | + }); |
| 129 | + |
| 130 | + } |
| 131 | + |
| 132 | + function toggleCell(noteIndex, step) { |
| 133 | + if (grid === null) grid = {}; // Initialize if still null |
| 134 | + const noteKey = String(noteIndex); |
| 135 | + const stepKey = String(step); |
| 136 | + if (!grid[noteKey]) grid[noteKey] = {}; |
| 137 | + |
| 138 | + // Explicit toggle: if undefined or false, set to true; if true, set to false |
| 139 | + const currentValue = grid[noteKey][stepKey] === true; |
| 140 | + const newValue = !currentValue; |
| 141 | + grid[noteKey][stepKey] = newValue; |
| 142 | + |
| 143 | + log.info(`Toggle cell [${NOTES[noteIndex]}][step ${step}]: ${currentValue} -> ${newValue}`); |
| 144 | + log.info('Grid before emit:', JSON.stringify(grid)); |
| 145 | + renderGrid(); |
| 146 | + socket.emit('composer:update_grid', { grid }); |
| 147 | + } |
| 148 | + |
| 149 | + function renderGrid() { |
| 150 | + if (grid === null) { |
| 151 | + log.info('Grid is null, skipping render'); |
| 152 | + return; // Don't render until we have state from server |
| 153 | + } |
| 154 | + log.info('Rendering grid:', JSON.stringify(grid)); |
| 155 | + const cells = document.querySelectorAll('.grid-cell'); |
| 156 | + let activeCount = 0; |
| 157 | + let activeCells = []; |
| 158 | + cells.forEach(cell => { |
| 159 | + const noteKey = String(cell.dataset.note); |
| 160 | + const stepKey = String(cell.dataset.step); |
| 161 | + const isActive = grid[noteKey] && grid[noteKey][stepKey] === true; |
| 162 | + |
| 163 | + // Force remove class first, then add if needed |
| 164 | + cell.classList.remove('active'); |
| 165 | + if (isActive) { |
| 166 | + cell.classList.add('active'); |
| 167 | + activeCount++; |
| 168 | + activeCells.push(`[${NOTES[noteKey]}][step ${stepKey}]`); |
| 169 | + } |
| 170 | + }); |
| 171 | + log.info(`Rendered ${activeCount} active cells: ${activeCells.join(', ')}`); |
| 172 | + } |
| 173 | + |
| 174 | + function highlightStep(step) { |
| 175 | + const cells = document.querySelectorAll('.grid-cell'); |
| 176 | + cells.forEach(cell => { |
| 177 | + const cellStep = parseInt(cell.dataset.step); |
| 178 | + cell.classList.toggle('playing', cellStep === step); |
| 179 | + }); |
| 180 | + } |
| 181 | + |
| 182 | + // Play button |
| 183 | + playBtn.addEventListener('click', () => { |
| 184 | + if (!isPlaying) { |
| 185 | + isPlaying = true; |
| 186 | + playBtn.style.display = 'none'; |
| 187 | + stopBtn.style.display = 'flex'; |
| 188 | + log.info('Starting playback at', bpm, 'BPM'); |
| 189 | + socket.emit('composer:play', { grid, bpm }); |
| 190 | + } |
| 191 | + }); |
| 192 | + |
| 193 | + // Stop button |
| 194 | + stopBtn.addEventListener('click', () => { |
| 195 | + if (isPlaying) { |
| 196 | + isPlaying = false; |
| 197 | + stopBtn.style.display = 'none'; |
| 198 | + playBtn.style.display = 'flex'; |
| 199 | + log.info('Stopping playback'); |
| 200 | + socket.emit('composer:stop', {}); |
| 201 | + highlightStep(-1); |
| 202 | + } |
| 203 | + }); |
| 204 | + |
| 205 | + // BPM controls |
| 206 | + bpmInput.addEventListener('change', () => { |
| 207 | + bpm = parseInt(bpmInput.value); |
| 208 | + log.info('BPM changed to:', bpm); |
| 209 | + socket.emit('composer:set_bpm', { bpm }); |
| 210 | + }); |
| 211 | + |
| 212 | + resetBpmBtn.addEventListener('click', () => { |
| 213 | + bpm = 120; |
| 214 | + bpmInput.value = bpm; |
| 215 | + log.info('BPM reset to 120'); |
| 216 | + socket.emit('composer:set_bpm', { bpm }); |
| 217 | + }); |
| 218 | + |
| 219 | + // Clear button |
| 220 | + clearBtn.addEventListener('click', () => { |
| 221 | + if (confirm('Clear all notes?')) { |
| 222 | + grid = {}; |
| 223 | + NOTES.forEach((note, noteIndex) => { |
| 224 | + const noteKey = String(noteIndex); |
| 225 | + grid[noteKey] = {}; |
| 226 | + }); |
| 227 | + renderGrid(); |
| 228 | + socket.emit('composer:update_grid', { grid }); |
| 229 | + } |
| 230 | + }); |
| 231 | + |
| 232 | + // Export button |
| 233 | + exportBtn.addEventListener('click', () => { |
| 234 | + socket.emit('composer:export', { grid }); |
| 235 | + }); |
| 236 | + |
| 237 | + socket.on('composer:export_data', (data) => { |
| 238 | + const blob = new Blob([data.content], { type: 'text/plain' }); |
| 239 | + const url = URL.createObjectURL(blob); |
| 240 | + const a = document.createElement('a'); |
| 241 | + a.href = url; |
| 242 | + a.download = data.filename || 'composition.h'; |
| 243 | + a.click(); |
| 244 | + URL.revokeObjectURL(url); |
| 245 | + }); |
| 246 | + |
| 247 | + // Wave buttons |
| 248 | + waveButtons.forEach(btn => { |
| 249 | + btn.addEventListener('click', () => { |
| 250 | + waveButtons.forEach(b => b.classList.remove('active')); |
| 251 | + btn.classList.add('active'); |
| 252 | + const wave = btn.dataset.wave; |
| 253 | + socket.emit('composer:set_waveform', { waveform: wave }); |
| 254 | + }); |
| 255 | + }); |
| 256 | + |
| 257 | + // Volume slider |
| 258 | + volumeSlider.addEventListener('input', () => { |
| 259 | + const volume = parseInt(volumeSlider.value); |
| 260 | + socket.emit('composer:set_volume', { volume }); |
| 261 | + }); |
| 262 | + |
| 263 | + // Knobs |
| 264 | + knobs.forEach(knob => { |
| 265 | + let isDragging = false; |
| 266 | + let startY = 0; |
| 267 | + let startValue = 0; |
| 268 | + |
| 269 | + knob.addEventListener('mousedown', (e) => { |
| 270 | + isDragging = true; |
| 271 | + startY = e.clientY; |
| 272 | + startValue = parseFloat(knob.dataset.value) || 0; |
| 273 | + e.preventDefault(); |
| 274 | + }); |
| 275 | + |
| 276 | + document.addEventListener('mousemove', (e) => { |
| 277 | + if (!isDragging) return; |
| 278 | + |
| 279 | + const delta = (startY - e.clientY) * 0.5; |
| 280 | + let newValue = startValue + delta; |
| 281 | + newValue = Math.max(0, Math.min(100, newValue)); |
| 282 | + |
| 283 | + knob.dataset.value = newValue; |
| 284 | + const rotation = (newValue / 100) * 270 - 135; |
| 285 | + knob.querySelector('.knob-indicator').style.transform = |
| 286 | + `translateX(-50%) rotate(${rotation}deg)`; |
| 287 | + |
| 288 | + const effectName = knob.id.replace('-knob', ''); |
| 289 | + effects[effectName] = newValue; |
| 290 | + }); |
| 291 | + |
| 292 | + document.addEventListener('mouseup', () => { |
| 293 | + if (isDragging) { |
| 294 | + isDragging = false; |
| 295 | + socket.emit('composer:set_effects', { effects }); |
| 296 | + } |
| 297 | + }); |
| 298 | + }); |
| 299 | + |
| 300 | + function updateEffectsKnobs() { |
| 301 | + Object.keys(effects).forEach(key => { |
| 302 | + const knob = document.getElementById(`${key}-knob`); |
| 303 | + if (knob) { |
| 304 | + const value = effects[key] || 0; |
| 305 | + knob.dataset.value = value; |
| 306 | + const rotation = (value / 100) * 270 - 135; |
| 307 | + knob.querySelector('.knob-indicator').style.transform = |
| 308 | + `translateX(-50%) rotate(${rotation}deg)`; |
| 309 | + } |
| 310 | + }); |
| 311 | + } |
| 312 | + |
| 313 | + // Initialize grid |
| 314 | + buildGrid(); |
| 315 | + |
| 316 | + // Ensure play button is visible and stop button is hidden on load |
| 317 | + playBtn.style.display = 'flex'; |
| 318 | + stopBtn.style.display = 'none'; |
| 319 | + log.info('Grid UI built, waiting for server state...'); |
| 320 | + |
| 321 | +})(); |
0 commit comments