Skip to content

Commit e70a79a

Browse files
committed
music composer stub
1 parent 6d2c39b commit e70a79a

File tree

12 files changed

+1383
-0
lines changed

12 files changed

+1383
-0
lines changed

examples/music-composer/app.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
name: Music Composer
2+
icon: 🎵
3+
description: A music composer app that lets you create melodies by composing notes with different durations and play them using sound generation.
4+
bricks:
5+
- arduino:web_ui
6+
- arduino:sound_generator
Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
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

Comments
 (0)