diff --git a/examples/music-composer/app.yaml b/examples/music-composer/app.yaml new file mode 100644 index 0000000..e4d9675 --- /dev/null +++ b/examples/music-composer/app.yaml @@ -0,0 +1,6 @@ +name: Music Composer +icon: 🎵 +description: A music composer app that lets you create melodies by composing notes with different durations and play them using sound generation. +bricks: + - arduino:web_ui + - arduino:sound_generator diff --git a/examples/music-composer/assets/app.js b/examples/music-composer/assets/app.js new file mode 100644 index 0000000..b066ce0 --- /dev/null +++ b/examples/music-composer/assets/app.js @@ -0,0 +1,321 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +(function(){ + const socket = io({ transports: ['websocket'] }); + + // Logger utility + const log = { + info: (msg, ...args) => console.log(`[MusicComposer] ${msg}`, ...args), + debug: (msg, ...args) => console.debug(`[MusicComposer] ${msg}`, ...args), + warn: (msg, ...args) => console.warn(`[MusicComposer] ${msg}`, ...args), + error: (msg, ...args) => console.error(`[MusicComposer] ${msg}`, ...args) + }; + + // Configuration + const GRID_STEPS = 16; + const NOTES = ['B4', 'A#4', 'A4', 'G#4', 'G4', 'F#4', 'F4', 'E4', 'D#4', 'D4', 'C#4', 'C4']; + + // State + let grid = null; // {noteIndex: {stepIndex: true/false}} - null until server sends state + let isPlaying = false; + let currentStep = 0; + let bpm = 120; + let playInterval = null; + let effects = { + reverb: 0, + chorus: 0, + tremolo: 0, + vibrato: 0, + overdrive: 0 + }; + + // DOM elements + const playBtn = document.getElementById('play-btn'); + const stopBtn = document.getElementById('stop-btn'); + const bpmInput = document.getElementById('bpm-input'); + const resetBpmBtn = document.getElementById('reset-bpm'); + const undoBtn = document.getElementById('undo-btn'); + const redoBtn = document.getElementById('redo-btn'); + const clearBtn = document.getElementById('clear-btn'); + const exportBtn = document.getElementById('export-btn'); + const sequencerGrid = document.getElementById('sequencer-grid'); + const volumeSlider = document.getElementById('volume-slider'); + const waveButtons = document.querySelectorAll('.wave-btn'); + const knobs = document.querySelectorAll('.knob'); + + // Initialize + socket.on('connect', () => { + log.info('Connected to server'); + socket.emit('composer:get_state', {}); + }); + + // Socket events + socket.on('composer:state', (data) => { + log.info('Received state from server:', JSON.stringify(data)); + if (data.grid) { + const oldGrid = JSON.stringify(grid); + grid = data.grid; + const newGrid = JSON.stringify(grid); + if (oldGrid !== newGrid) { + log.info('Grid changed from', oldGrid, 'to', newGrid); + } + } else { + // Initialize empty grid if server sends nothing + grid = {}; + log.info('Grid initialized as empty'); + } + if (data.bpm) { + bpm = data.bpm; + bpmInput.value = bpm; + log.info('BPM updated:', bpm); + } + if (data.effects) { + effects = data.effects; + log.info('Effects updated:', effects); + } + renderGrid(); + updateEffectsKnobs(); + }); + + socket.on('composer:step_playing', (data) => { + log.debug('Step playing:', data.step); + highlightStep(data.step); + }); + + // Build grid + function buildGrid() { + sequencerGrid.innerHTML = ''; + + // Top-left corner (empty) + const corner = document.createElement('div'); + sequencerGrid.appendChild(corner); + + // Column labels (step numbers) + for (let step = 0; step < GRID_STEPS; step++) { + const label = document.createElement('div'); + label.className = 'grid-col-label'; + label.textContent = step + 1; + sequencerGrid.appendChild(label); + } + + // Grid rows + NOTES.forEach((note, noteIndex) => { + // Row label (note name) + const rowLabel = document.createElement('div'); + rowLabel.className = 'grid-row-label'; + rowLabel.textContent = note; + sequencerGrid.appendChild(rowLabel); + + // Grid cells + for (let step = 0; step < GRID_STEPS; step++) { + const cell = document.createElement('div'); + cell.className = 'grid-cell'; + cell.dataset.note = noteIndex; + cell.dataset.step = step; + + // Add beat separator every 4 steps + if ((step + 1) % 4 === 0 && step < GRID_STEPS - 1) { + cell.classList.add('beat-separator'); + } + + cell.addEventListener('click', () => toggleCell(noteIndex, step)); + sequencerGrid.appendChild(cell); + } + }); + + } + + function toggleCell(noteIndex, step) { + if (grid === null) grid = {}; // Initialize if still null + const noteKey = String(noteIndex); + const stepKey = String(step); + if (!grid[noteKey]) grid[noteKey] = {}; + + // Explicit toggle: if undefined or false, set to true; if true, set to false + const currentValue = grid[noteKey][stepKey] === true; + const newValue = !currentValue; + grid[noteKey][stepKey] = newValue; + + log.info(`Toggle cell [${NOTES[noteIndex]}][step ${step}]: ${currentValue} -> ${newValue}`); + log.info('Grid before emit:', JSON.stringify(grid)); + renderGrid(); + socket.emit('composer:update_grid', { grid }); + } + + function renderGrid() { + if (grid === null) { + log.info('Grid is null, skipping render'); + return; // Don't render until we have state from server + } + log.info('Rendering grid:', JSON.stringify(grid)); + const cells = document.querySelectorAll('.grid-cell'); + let activeCount = 0; + let activeCells = []; + cells.forEach(cell => { + const noteKey = String(cell.dataset.note); + const stepKey = String(cell.dataset.step); + const isActive = grid[noteKey] && grid[noteKey][stepKey] === true; + + // Force remove class first, then add if needed + cell.classList.remove('active'); + if (isActive) { + cell.classList.add('active'); + activeCount++; + activeCells.push(`[${NOTES[noteKey]}][step ${stepKey}]`); + } + }); + log.info(`Rendered ${activeCount} active cells: ${activeCells.join(', ')}`); + } + + function highlightStep(step) { + const cells = document.querySelectorAll('.grid-cell'); + cells.forEach(cell => { + const cellStep = parseInt(cell.dataset.step); + cell.classList.toggle('playing', cellStep === step); + }); + } + + // Play button + playBtn.addEventListener('click', () => { + if (!isPlaying) { + isPlaying = true; + playBtn.style.display = 'none'; + stopBtn.style.display = 'flex'; + log.info('Starting playback at', bpm, 'BPM'); + socket.emit('composer:play', { grid, bpm }); + } + }); + + // Stop button + stopBtn.addEventListener('click', () => { + if (isPlaying) { + isPlaying = false; + stopBtn.style.display = 'none'; + playBtn.style.display = 'flex'; + log.info('Stopping playback'); + socket.emit('composer:stop', {}); + highlightStep(-1); + } + }); + + // BPM controls + bpmInput.addEventListener('change', () => { + bpm = parseInt(bpmInput.value); + log.info('BPM changed to:', bpm); + socket.emit('composer:set_bpm', { bpm }); + }); + + resetBpmBtn.addEventListener('click', () => { + bpm = 120; + bpmInput.value = bpm; + log.info('BPM reset to 120'); + socket.emit('composer:set_bpm', { bpm }); + }); + + // Clear button + clearBtn.addEventListener('click', () => { + if (confirm('Clear all notes?')) { + grid = {}; + NOTES.forEach((note, noteIndex) => { + const noteKey = String(noteIndex); + grid[noteKey] = {}; + }); + renderGrid(); + socket.emit('composer:update_grid', { grid }); + } + }); + + // Export button + exportBtn.addEventListener('click', () => { + socket.emit('composer:export', { grid }); + }); + + socket.on('composer:export_data', (data) => { + const blob = new Blob([data.content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = data.filename || 'composition.h'; + a.click(); + URL.revokeObjectURL(url); + }); + + // Wave buttons + waveButtons.forEach(btn => { + btn.addEventListener('click', () => { + waveButtons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const wave = btn.dataset.wave; + socket.emit('composer:set_waveform', { waveform: wave }); + }); + }); + + // Volume slider + volumeSlider.addEventListener('input', () => { + const volume = parseInt(volumeSlider.value); + socket.emit('composer:set_volume', { volume }); + }); + + // Knobs + knobs.forEach(knob => { + let isDragging = false; + let startY = 0; + let startValue = 0; + + knob.addEventListener('mousedown', (e) => { + isDragging = true; + startY = e.clientY; + startValue = parseFloat(knob.dataset.value) || 0; + e.preventDefault(); + }); + + document.addEventListener('mousemove', (e) => { + if (!isDragging) return; + + const delta = (startY - e.clientY) * 0.5; + let newValue = startValue + delta; + newValue = Math.max(0, Math.min(100, newValue)); + + knob.dataset.value = newValue; + const rotation = (newValue / 100) * 270 - 135; + knob.querySelector('.knob-indicator').style.transform = + `translateX(-50%) rotate(${rotation}deg)`; + + const effectName = knob.id.replace('-knob', ''); + effects[effectName] = newValue; + }); + + document.addEventListener('mouseup', () => { + if (isDragging) { + isDragging = false; + socket.emit('composer:set_effects', { effects }); + } + }); + }); + + function updateEffectsKnobs() { + Object.keys(effects).forEach(key => { + const knob = document.getElementById(`${key}-knob`); + if (knob) { + const value = effects[key] || 0; + knob.dataset.value = value; + const rotation = (value / 100) * 270 - 135; + knob.querySelector('.knob-indicator').style.transform = + `translateX(-50%) rotate(${rotation}deg)`; + } + }); + } + + // Initialize grid + buildGrid(); + + // Ensure play button is visible and stop button is hidden on load + playBtn.style.display = 'flex'; + stopBtn.style.display = 'none'; + log.info('Grid UI built, waiting for server state...'); + +})(); diff --git a/examples/music-composer/assets/fonts/Open Sans/OFL.txt b/examples/music-composer/assets/fonts/Open Sans/OFL.txt new file mode 100644 index 0000000..d2a4922 --- /dev/null +++ b/examples/music-composer/assets/fonts/Open Sans/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/examples/music-composer/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf b/examples/music-composer/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf new file mode 100644 index 0000000..548c15f Binary files /dev/null and b/examples/music-composer/assets/fonts/Open Sans/OpenSans-VariableFont_wdth,wght.ttf differ diff --git a/examples/music-composer/assets/fonts/Roboto/OFL.txt b/examples/music-composer/assets/fonts/Roboto/OFL.txt new file mode 100644 index 0000000..5d6f71c --- /dev/null +++ b/examples/music-composer/assets/fonts/Roboto/OFL.txt @@ -0,0 +1,91 @@ +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/music-composer/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf b/examples/music-composer/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf new file mode 100644 index 0000000..3a2d704 Binary files /dev/null and b/examples/music-composer/assets/fonts/Roboto/RobotoMono-VariableFont_wght.ttf differ diff --git a/examples/music-composer/assets/fonts/fonts.css b/examples/music-composer/assets/fonts/fonts.css new file mode 100644 index 0000000..86cf716 --- /dev/null +++ b/examples/music-composer/assets/fonts/fonts.css @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-display: swap; + src: url('Roboto/RobotoMono-VariableFont_wght.ttf') format('truetype'); +} + +@font-face { + font-family: 'Open Sans'; + font-style: normal; + font-display: swap; + src: url('Open Sans/OpenSans-VariableFont_wdth,wght.ttf') format('truetype'); +} \ No newline at end of file diff --git a/examples/music-composer/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg b/examples/music-composer/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg new file mode 100644 index 0000000..c942003 --- /dev/null +++ b/examples/music-composer/assets/img/RGB-Arduino-Logo_Color Inline Loop.svg @@ -0,0 +1,19 @@ + + \ No newline at end of file diff --git a/examples/music-composer/assets/index.html b/examples/music-composer/assets/index.html new file mode 100644 index 0000000..157802f --- /dev/null +++ b/examples/music-composer/assets/index.html @@ -0,0 +1,118 @@ + + + + + + + + Music Composer + + + +
+ +
+
+ +

Music Composer

+
+ +
+ + + +
+
4/4 BPM
+
+ + +
+
+
+ +
+ + + + +
+
+ + +
+
+ +
+
+ + +
+ +
+

Effects

+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ + +
+

Wave

+
+ + + +
+
+ + +
+
+ +
🔊
+
+
+
+
+ + + + + diff --git a/examples/music-composer/assets/libs/socket.io.min.js b/examples/music-composer/assets/libs/socket.io.min.js new file mode 100644 index 0000000..530b185 --- /dev/null +++ b/examples/music-composer/assets/libs/socket.io.min.js @@ -0,0 +1,6 @@ +/*! + * Socket.IO v4.8.1 + * (c) 2014-2024 Guillermo Rauch + * Released under the MIT License. + */ +!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(t="undefined"!=typeof globalThis?globalThis:t||self).io=n()}(this,(function(){"use strict";function t(t,n){(null==n||n>t.length)&&(n=t.length);for(var i=0,r=Array(n);i=n.length?{done:!0}:{done:!1,value:n[e++]}},e:function(t){throw t},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,u=!0,h=!1;return{s:function(){r=r.call(n)},n:function(){var t=r.next();return u=t.done,t},e:function(t){h=!0,s=t},f:function(){try{u||null==r.return||r.return()}finally{if(h)throw s}}}}function e(){return e=Object.assign?Object.assign.bind():function(t){for(var n=1;n1?{type:l[i],data:t.substring(1)}:{type:l[i]}:d},N=function(t,n){if(B){var i=function(t){var n,i,r,e,o,s=.75*t.length,u=t.length,h=0;"="===t[t.length-1]&&(s--,"="===t[t.length-2]&&s--);var f=new ArrayBuffer(s),c=new Uint8Array(f);for(n=0;n>4,c[h++]=(15&r)<<4|e>>2,c[h++]=(3&e)<<6|63&o;return f}(t);return C(i,n)}return{base64:!0,data:t}},C=function(t,n){return"blob"===n?t instanceof Blob?t:new Blob([t]):t instanceof ArrayBuffer?t:t.buffer},T=String.fromCharCode(30);function U(){return new TransformStream({transform:function(t,n){!function(t,n){y&&t.data instanceof Blob?t.data.arrayBuffer().then(k).then(n):b&&(t.data instanceof ArrayBuffer||w(t.data))?n(k(t.data)):g(t,!1,(function(t){p||(p=new TextEncoder),n(p.encode(t))}))}(t,(function(i){var r,e=i.length;if(e<126)r=new Uint8Array(1),new DataView(r.buffer).setUint8(0,e);else if(e<65536){r=new Uint8Array(3);var o=new DataView(r.buffer);o.setUint8(0,126),o.setUint16(1,e)}else{r=new Uint8Array(9);var s=new DataView(r.buffer);s.setUint8(0,127),s.setBigUint64(1,BigInt(e))}t.data&&"string"!=typeof t.data&&(r[0]|=128),n.enqueue(r),n.enqueue(i)}))}})}function M(t){return t.reduce((function(t,n){return t+n.length}),0)}function x(t,n){if(t[0].length===n)return t.shift();for(var i=new Uint8Array(n),r=0,e=0;e1?n-1:0),r=1;r1&&void 0!==arguments[1]?arguments[1]:{};return t+"://"+this.i()+this.o()+this.opts.path+this.u(n)},i.i=function(){var t=this.opts.hostname;return-1===t.indexOf(":")?t:"["+t+"]"},i.o=function(){return this.opts.port&&(this.opts.secure&&Number(443!==this.opts.port)||!this.opts.secure&&80!==Number(this.opts.port))?":"+this.opts.port:""},i.u=function(t){var n=function(t){var n="";for(var i in t)t.hasOwnProperty(i)&&(n.length&&(n+="&"),n+=encodeURIComponent(i)+"="+encodeURIComponent(t[i]));return n}(t);return n.length?"?"+n:""},n}(I),X=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).h=!1,n}s(n,t);var r=n.prototype;return r.doOpen=function(){this.v()},r.pause=function(t){var n=this;this.readyState="pausing";var i=function(){n.readyState="paused",t()};if(this.h||!this.writable){var r=0;this.h&&(r++,this.once("pollComplete",(function(){--r||i()}))),this.writable||(r++,this.once("drain",(function(){--r||i()})))}else i()},r.v=function(){this.h=!0,this.doPoll(),this.emitReserved("poll")},r.onData=function(t){var n=this;(function(t,n){for(var i=t.split(T),r=[],e=0;e0&&void 0!==arguments[0]?arguments[0]:{};return e(t,{xd:this.xd},this.opts),new Y(tt,this.uri(),t)},n}(K);function tt(t){var n=t.xdomain;try{if("undefined"!=typeof XMLHttpRequest&&(!n||z))return new XMLHttpRequest}catch(t){}if(!n)try{return new(L[["Active"].concat("Object").join("X")])("Microsoft.XMLHTTP")}catch(t){}}var nt="undefined"!=typeof navigator&&"string"==typeof navigator.product&&"reactnative"===navigator.product.toLowerCase(),it=function(t){function n(){return t.apply(this,arguments)||this}s(n,t);var r=n.prototype;return r.doOpen=function(){var t=this.uri(),n=this.opts.protocols,i=nt?{}:_(this.opts,"agent","perMessageDeflate","pfx","key","passphrase","cert","ca","ciphers","rejectUnauthorized","localAddress","protocolVersion","origin","maxPayload","family","checkServerIdentity");this.opts.extraHeaders&&(i.headers=this.opts.extraHeaders);try{this.ws=this.createSocket(t,n,i)}catch(t){return this.emitReserved("error",t)}this.ws.binaryType=this.socket.binaryType,this.addEventListeners()},r.addEventListeners=function(){var t=this;this.ws.onopen=function(){t.opts.autoUnref&&t.ws.C.unref(),t.onOpen()},this.ws.onclose=function(n){return t.onClose({description:"websocket connection closed",context:n})},this.ws.onmessage=function(n){return t.onData(n.data)},this.ws.onerror=function(n){return t.onError("websocket error",n)}},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;g(i,n.supportsBinary,(function(t){try{n.doWrite(i,t)}catch(t){}e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;rMath.pow(2,21)-1){u.enqueue(d);break}e=v*Math.pow(2,32)+a.getUint32(4),r=3}else{if(M(i)t){u.enqueue(d);break}}}})}(Number.MAX_SAFE_INTEGER,t.socket.binaryType),r=n.readable.pipeThrough(i).getReader(),e=U();e.readable.pipeTo(n.writable),t.U=e.writable.getWriter();!function n(){r.read().then((function(i){var r=i.done,e=i.value;r||(t.onPacket(e),n())})).catch((function(t){}))}();var o={type:"open"};t.query.sid&&(o.data='{"sid":"'.concat(t.query.sid,'"}')),t.U.write(o).then((function(){return t.onOpen()}))}))}))},r.write=function(t){var n=this;this.writable=!1;for(var i=function(){var i=t[r],e=r===t.length-1;n.U.write(i).then((function(){e&&R((function(){n.writable=!0,n.emitReserved("drain")}),n.setTimeoutFn)}))},r=0;r8e3)throw"URI too long";var n=t,i=t.indexOf("["),r=t.indexOf("]");-1!=i&&-1!=r&&(t=t.substring(0,i)+t.substring(i,r).replace(/:/g,";")+t.substring(r,t.length));for(var e,o,s=ut.exec(t||""),u={},h=14;h--;)u[ht[h]]=s[h]||"";return-1!=i&&-1!=r&&(u.source=n,u.host=u.host.substring(1,u.host.length-1).replace(/;/g,":"),u.authority=u.authority.replace("[","").replace("]","").replace(/;/g,":"),u.ipv6uri=!0),u.pathNames=function(t,n){var i=/\/{2,9}/g,r=n.replace(i,"/").split("/");"/"!=n.slice(0,1)&&0!==n.length||r.splice(0,1);"/"==n.slice(-1)&&r.splice(r.length-1,1);return r}(0,u.path),u.queryKey=(e=u.query,o={},e.replace(/(?:^|&)([^&=]*)=?([^&]*)/g,(function(t,n,i){n&&(o[n]=i)})),o),u}var ct="function"==typeof addEventListener&&"function"==typeof removeEventListener,at=[];ct&&addEventListener("offline",(function(){at.forEach((function(t){return t()}))}),!1);var vt=function(t){function n(n,i){var r;if((r=t.call(this)||this).binaryType="arraybuffer",r.writeBuffer=[],r.M=0,r.I=-1,r.R=-1,r.L=-1,r._=1/0,n&&"object"===c(n)&&(i=n,n=null),n){var o=ft(n);i.hostname=o.host,i.secure="https"===o.protocol||"wss"===o.protocol,i.port=o.port,o.query&&(i.query=o.query)}else i.host&&(i.hostname=ft(i.host).host);return $(r,i),r.secure=null!=i.secure?i.secure:"undefined"!=typeof location&&"https:"===location.protocol,i.hostname&&!i.port&&(i.port=r.secure?"443":"80"),r.hostname=i.hostname||("undefined"!=typeof location?location.hostname:"localhost"),r.port=i.port||("undefined"!=typeof location&&location.port?location.port:r.secure?"443":"80"),r.transports=[],r.D={},i.transports.forEach((function(t){var n=t.prototype.name;r.transports.push(n),r.D[n]=t})),r.opts=e({path:"/engine.io",agent:!1,withCredentials:!1,upgrade:!0,timestampParam:"t",rememberUpgrade:!1,addTrailingSlash:!0,rejectUnauthorized:!0,perMessageDeflate:{threshold:1024},transportOptions:{},closeOnBeforeunload:!1},i),r.opts.path=r.opts.path.replace(/\/$/,"")+(r.opts.addTrailingSlash?"/":""),"string"==typeof r.opts.query&&(r.opts.query=function(t){for(var n={},i=t.split("&"),r=0,e=i.length;r1))return this.writeBuffer;for(var t,n=1,i=0;i=57344?i+=3:(r++,i+=4);return i}(t):Math.ceil(1.33*(t.byteLength||t.size))),i>0&&n>this.L)return this.writeBuffer.slice(0,i);n+=2}return this.writeBuffer},i.W=function(){var t=this;if(!this._)return!0;var n=Date.now()>this._;return n&&(this._=0,R((function(){t.F("ping timeout")}),this.setTimeoutFn)),n},i.write=function(t,n,i){return this.J("message",t,n,i),this},i.send=function(t,n,i){return this.J("message",t,n,i),this},i.J=function(t,n,i,r){if("function"==typeof n&&(r=n,n=void 0),"function"==typeof i&&(r=i,i=null),"closing"!==this.readyState&&"closed"!==this.readyState){(i=i||{}).compress=!1!==i.compress;var e={type:t,data:n,options:i};this.emitReserved("packetCreate",e),this.writeBuffer.push(e),r&&this.once("flush",r),this.flush()}},i.close=function(){var t=this,n=function(){t.F("forced close"),t.transport.close()},i=function i(){t.off("upgrade",i),t.off("upgradeError",i),n()},r=function(){t.once("upgrade",i),t.once("upgradeError",i)};return"opening"!==this.readyState&&"open"!==this.readyState||(this.readyState="closing",this.writeBuffer.length?this.once("drain",(function(){t.upgrading?r():n()})):this.upgrading?r():n()),this},i.B=function(t){if(n.priorWebsocketSuccess=!1,this.opts.tryAllTransports&&this.transports.length>1&&"opening"===this.readyState)return this.transports.shift(),this.q();this.emitReserved("error",t),this.F("transport error",t)},i.F=function(t,n){if("opening"===this.readyState||"open"===this.readyState||"closing"===this.readyState){if(this.clearTimeoutFn(this.Y),this.transport.removeAllListeners("close"),this.transport.close(),this.transport.removeAllListeners(),ct&&(this.P&&removeEventListener("beforeunload",this.P,!1),this.$)){var i=at.indexOf(this.$);-1!==i&&at.splice(i,1)}this.readyState="closed",this.id=null,this.emitReserved("close",t,n),this.writeBuffer=[],this.M=0}},n}(I);vt.protocol=4;var lt=function(t){function n(){var n;return(n=t.apply(this,arguments)||this).Z=[],n}s(n,t);var i=n.prototype;return i.onOpen=function(){if(t.prototype.onOpen.call(this),"open"===this.readyState&&this.opts.upgrade)for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{},r="object"===c(n)?n:i;return(!r.transports||r.transports&&"string"==typeof r.transports[0])&&(r.transports=(r.transports||["polling","websocket","webtransport"]).map((function(t){return st[t]})).filter((function(t){return!!t}))),t.call(this,n,r)||this}return s(n,t),n}(lt);pt.protocol;var dt="function"==typeof ArrayBuffer,yt=function(t){return"function"==typeof ArrayBuffer.isView?ArrayBuffer.isView(t):t.buffer instanceof ArrayBuffer},bt=Object.prototype.toString,wt="function"==typeof Blob||"undefined"!=typeof Blob&&"[object BlobConstructor]"===bt.call(Blob),gt="function"==typeof File||"undefined"!=typeof File&&"[object FileConstructor]"===bt.call(File);function mt(t){return dt&&(t instanceof ArrayBuffer||yt(t))||wt&&t instanceof Blob||gt&&t instanceof File}function kt(t,n){if(!t||"object"!==c(t))return!1;if(Array.isArray(t)){for(var i=0,r=t.length;i=0&&t.num1?e-1:0),s=1;s1?i-1:0),e=1;ei.l.retries&&(i.it.shift(),n&&n(t));else if(i.it.shift(),n){for(var e=arguments.length,o=new Array(e>1?e-1:0),s=1;s0&&void 0!==arguments[0]&&arguments[0];if(this.connected&&0!==this.it.length){var n=this.it[0];n.pending&&!t||(n.pending=!0,n.tryCount++,this.flags=n.flags,this.emit.apply(this,n.args))}},o.packet=function(t){t.nsp=this.nsp,this.io.ct(t)},o.onopen=function(){var t=this;"function"==typeof this.auth?this.auth((function(n){t.vt(n)})):this.vt(this.auth)},o.vt=function(t){this.packet({type:Bt.CONNECT,data:this.lt?e({pid:this.lt,offset:this.dt},t):t})},o.onerror=function(t){this.connected||this.emitReserved("connect_error",t)},o.onclose=function(t,n){this.connected=!1,delete this.id,this.emitReserved("disconnect",t,n),this.yt()},o.yt=function(){var t=this;Object.keys(this.acks).forEach((function(n){if(!t.sendBuffer.some((function(t){return String(t.id)===n}))){var i=t.acks[n];delete t.acks[n],i.withError&&i.call(t,new Error("socket has been disconnected"))}}))},o.onpacket=function(t){if(t.nsp===this.nsp)switch(t.type){case Bt.CONNECT:t.data&&t.data.sid?this.onconnect(t.data.sid,t.data.pid):this.emitReserved("connect_error",new Error("It seems you are trying to reach a Socket.IO server in v2.x with a v3.x client, but they are not compatible (more information here: https://socket.io/docs/v3/migrating-from-2-x-to-3-0/)"));break;case Bt.EVENT:case Bt.BINARY_EVENT:this.onevent(t);break;case Bt.ACK:case Bt.BINARY_ACK:this.onack(t);break;case Bt.DISCONNECT:this.ondisconnect();break;case Bt.CONNECT_ERROR:this.destroy();var n=new Error(t.data.message);n.data=t.data.data,this.emitReserved("connect_error",n)}},o.onevent=function(t){var n=t.data||[];null!=t.id&&n.push(this.ack(t.id)),this.connected?this.emitEvent(n):this.receiveBuffer.push(Object.freeze(n))},o.emitEvent=function(n){if(this.bt&&this.bt.length){var i,e=r(this.bt.slice());try{for(e.s();!(i=e.n()).done;){i.value.apply(this,n)}}catch(t){e.e(t)}finally{e.f()}}t.prototype.emit.apply(this,n),this.lt&&n.length&&"string"==typeof n[n.length-1]&&(this.dt=n[n.length-1])},o.ack=function(t){var n=this,i=!1;return function(){if(!i){i=!0;for(var r=arguments.length,e=new Array(r),o=0;o0&&t.jitter<=1?t.jitter:0,this.attempts=0}_t.prototype.duration=function(){var t=this.ms*Math.pow(this.factor,this.attempts++);if(this.jitter){var n=Math.random(),i=Math.floor(n*this.jitter*t);t=1&Math.floor(10*n)?t+i:t-i}return 0|Math.min(t,this.max)},_t.prototype.reset=function(){this.attempts=0},_t.prototype.setMin=function(t){this.ms=t},_t.prototype.setMax=function(t){this.max=t},_t.prototype.setJitter=function(t){this.jitter=t};var Dt=function(t){function n(n,i){var r,e;(r=t.call(this)||this).nsps={},r.subs=[],n&&"object"===c(n)&&(i=n,n=void 0),(i=i||{}).path=i.path||"/socket.io",r.opts=i,$(r,i),r.reconnection(!1!==i.reconnection),r.reconnectionAttempts(i.reconnectionAttempts||1/0),r.reconnectionDelay(i.reconnectionDelay||1e3),r.reconnectionDelayMax(i.reconnectionDelayMax||5e3),r.randomizationFactor(null!==(e=i.randomizationFactor)&&void 0!==e?e:.5),r.backoff=new _t({min:r.reconnectionDelay(),max:r.reconnectionDelayMax(),jitter:r.randomizationFactor()}),r.timeout(null==i.timeout?2e4:i.timeout),r.st="closed",r.uri=n;var o=i.parser||xt;return r.encoder=new o.Encoder,r.decoder=new o.Decoder,r.et=!1!==i.autoConnect,r.et&&r.open(),r}s(n,t);var i=n.prototype;return i.reconnection=function(t){return arguments.length?(this.kt=!!t,t||(this.skipReconnect=!0),this):this.kt},i.reconnectionAttempts=function(t){return void 0===t?this.At:(this.At=t,this)},i.reconnectionDelay=function(t){var n;return void 0===t?this.jt:(this.jt=t,null===(n=this.backoff)||void 0===n||n.setMin(t),this)},i.randomizationFactor=function(t){var n;return void 0===t?this.Et:(this.Et=t,null===(n=this.backoff)||void 0===n||n.setJitter(t),this)},i.reconnectionDelayMax=function(t){var n;return void 0===t?this.Ot:(this.Ot=t,null===(n=this.backoff)||void 0===n||n.setMax(t),this)},i.timeout=function(t){return arguments.length?(this.Bt=t,this):this.Bt},i.maybeReconnectOnOpen=function(){!this.ot&&this.kt&&0===this.backoff.attempts&&this.reconnect()},i.open=function(t){var n=this;if(~this.st.indexOf("open"))return this;this.engine=new pt(this.uri,this.opts);var i=this.engine,r=this;this.st="opening",this.skipReconnect=!1;var e=It(i,"open",(function(){r.onopen(),t&&t()})),o=function(i){n.cleanup(),n.st="closed",n.emitReserved("error",i),t?t(i):n.maybeReconnectOnOpen()},s=It(i,"error",o);if(!1!==this.Bt){var u=this.Bt,h=this.setTimeoutFn((function(){e(),o(new Error("timeout")),i.close()}),u);this.opts.autoUnref&&h.unref(),this.subs.push((function(){n.clearTimeoutFn(h)}))}return this.subs.push(e),this.subs.push(s),this},i.connect=function(t){return this.open(t)},i.onopen=function(){this.cleanup(),this.st="open",this.emitReserved("open");var t=this.engine;this.subs.push(It(t,"ping",this.onping.bind(this)),It(t,"data",this.ondata.bind(this)),It(t,"error",this.onerror.bind(this)),It(t,"close",this.onclose.bind(this)),It(this.decoder,"decoded",this.ondecoded.bind(this)))},i.onping=function(){this.emitReserved("ping")},i.ondata=function(t){try{this.decoder.add(t)}catch(t){this.onclose("parse error",t)}},i.ondecoded=function(t){var n=this;R((function(){n.emitReserved("packet",t)}),this.setTimeoutFn)},i.onerror=function(t){this.emitReserved("error",t)},i.socket=function(t,n){var i=this.nsps[t];return i?this.et&&!i.active&&i.connect():(i=new Lt(this,t,n),this.nsps[t]=i),i},i.wt=function(t){for(var n=0,i=Object.keys(this.nsps);n=this.At)this.backoff.reset(),this.emitReserved("reconnect_failed"),this.ot=!1;else{var i=this.backoff.duration();this.ot=!0;var r=this.setTimeoutFn((function(){n.skipReconnect||(t.emitReserved("reconnect_attempt",n.backoff.attempts),n.skipReconnect||n.open((function(i){i?(n.ot=!1,n.reconnect(),t.emitReserved("reconnect_error",i)):n.onreconnect()})))}),i);this.opts.autoUnref&&r.unref(),this.subs.push((function(){t.clearTimeoutFn(r)}))}},i.onreconnect=function(){var t=this.backoff.attempts;this.ot=!1,this.backoff.reset(),this.emitReserved("reconnect",t)},n}(I),Pt={};function $t(t,n){"object"===c(t)&&(n=t,t=void 0);var i,r=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"",i=arguments.length>2?arguments[2]:void 0,r=t;i=i||"undefined"!=typeof location&&location,null==t&&(t=i.protocol+"//"+i.host),"string"==typeof t&&("/"===t.charAt(0)&&(t="/"===t.charAt(1)?i.protocol+t:i.host+t),/^(https?|wss?):\/\//.test(t)||(t=void 0!==i?i.protocol+"//"+t:"https://"+t),r=ft(t)),r.port||(/^(http|ws)$/.test(r.protocol)?r.port="80":/^(http|ws)s$/.test(r.protocol)&&(r.port="443")),r.path=r.path||"/";var e=-1!==r.host.indexOf(":")?"["+r.host+"]":r.host;return r.id=r.protocol+"://"+e+":"+r.port+n,r.href=r.protocol+"://"+e+(i&&i.port===r.port?"":":"+r.port),r}(t,(n=n||{}).path||"/socket.io"),e=r.source,o=r.id,s=r.path,u=Pt[o]&&s in Pt[o].nsps;return n.forceNew||n["force new connection"]||!1===n.multiplex||u?i=new Dt(e,n):(Pt[o]||(Pt[o]=new Dt(e,n)),i=Pt[o]),r.query&&!n.query&&(n.query=r.queryKey),i.socket(r.path,n)}return e($t,{Manager:Dt,Socket:Lt,io:$t,connect:$t}),$t})); \ No newline at end of file diff --git a/examples/music-composer/assets/style.css b/examples/music-composer/assets/style.css new file mode 100644 index 0000000..d2ad3bc --- /dev/null +++ b/examples/music-composer/assets/style.css @@ -0,0 +1,472 @@ +/* + * SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) + * + * SPDX-License-Identifier: MPL-2.0 + */ + +@import url('./fonts/fonts.css'); + +:root { + --bg-dark: #1a1a1a; + --bg-darker: #0d0d0d; + --cyan: #00d9ff; + --purple: #a855f7; + --text-light: #e0e0e0; + --text-dim: #888; + --grid-line: #333; + --grid-line-thick: #555; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Open Sans', Arial, sans-serif; + background-color: var(--bg-darker); + color: var(--text-light); + overflow-x: hidden; +} + +#app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.header { + background-color: var(--bg-dark); + padding: 15px 30px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 2px solid #000; +} + +.header-left { + display: flex; + align-items: center; + gap: 15px; +} + +.arduino-logo { + height: 32px; + filter: brightness(0) invert(1); +} + +.arduino-text { + font-family: 'Roboto', sans-serif; + font-size: 20px; + font-weight: 700; + color: var(--text-light); +} + +.header-center { + display: flex; + align-items: center; + gap: 25px; +} + +.play-button { + width: 50px; + height: 50px; + border-radius: 50%; + background: var(--cyan); + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.play-button:hover { + background: #00bfdd; + transform: scale(1.05); +} + +.play-button.playing { + background: var(--purple); +} + +.play-icon { + font-size: 20px; + color: #000; + margin-left: 3px; +} + +.stop-button { + width: 50px; + height: 50px; + border-radius: 50%; + background: #ff4444; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.stop-button:hover { + background: #dd3333; + transform: scale(1.05); +} + +.stop-icon { + font-size: 20px; + color: #000; +} + +.tempo-control { + display: flex; + flex-direction: column; + gap: 5px; +} + +.tempo-label { + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; +} + +.tempo-value { + display: flex; + align-items: center; + gap: 8px; +} + +#bpm-input { + width: 60px; + padding: 5px 8px; + background: var(--bg-darker); + border: 1px solid var(--grid-line-thick); + color: var(--cyan); + font-size: 18px; + font-weight: 700; + border-radius: 4px; + text-align: center; +} + +#bpm-input::-webkit-inner-spin-button, +#bpm-input::-webkit-outer-spin-button { + opacity: 1; +} + +.header-right { + display: flex; + gap: 10px; +} + +.icon-btn { + width: 36px; + height: 36px; + background: transparent; + border: 1px solid var(--grid-line-thick); + color: var(--text-light); + font-size: 18px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.icon-btn:hover { + background: var(--grid-line-thick); + border-color: var(--cyan); + color: var(--cyan); +} + +.export-btn { + padding: 8px 16px; + background: var(--cyan); + color: #000; + border: none; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.export-btn:hover { + background: #00bfdd; +} + +/* Sequencer Container */ +#sequencer-container { + flex: 1; + padding: 20px; + overflow: auto; + display: flex; + justify-content: center; + align-items: flex-start; +} + +#sequencer-grid { + display: grid; + grid-template-columns: 60px repeat(16, 40px); + gap: 0; + background: var(--bg-dark); + border: 1px solid #000; + padding: 10px; +} + +.grid-row-label { + height: 35px; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 10px; + font-size: 12px; + font-weight: 600; + color: var(--text-dim); + border-right: 2px solid var(--grid-line-thick); +} + +.grid-col-label { + height: 30px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: var(--text-dim); + border-bottom: 2px solid var(--grid-line-thick); +} + +.grid-cell { + width: 40px; + height: 35px; + background: var(--bg-darker); + border: 1px solid var(--grid-line); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; + position: relative; +} + +.grid-cell:hover { + background: #222; +} + +.grid-cell.active { + background: var(--purple); +} + +.grid-cell.active::before { + content: ''; + width: 20px; + height: 20px; + background: var(--purple); + border-radius: 3px; + box-shadow: 0 0 10px rgba(168, 85, 247, 0.6); +} + +.grid-cell.playing { + background: var(--cyan) !important; + box-shadow: 0 0 15px rgba(0, 217, 255, 0.8); +} + +.grid-cell.beat-separator { + border-right: 2px solid var(--grid-line-thick); +} + +/* Control Panel */ +#control-panel { + background: var(--bg-dark); + padding: 25px 30px; + display: flex; + gap: 40px; + border-top: 2px solid #000; +} + +.control-section { + display: flex; + flex-direction: column; + gap: 15px; +} + +.section-title { + font-size: 14px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.5px; + margin-bottom: 5px; +} + +/* Effects Section */ +.effects-section { + flex: 1; +} + +.knobs-container { + display: flex; + gap: 30px; +} + +.knob-control { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.knob { + width: 60px; + height: 60px; + border-radius: 50%; + background: linear-gradient(145deg, #1a1a1a, #0d0d0d); + border: 3px solid var(--grid-line-thick); + position: relative; + cursor: pointer; + transition: border-color 0.2s; +} + +.knob:hover { + border-color: var(--cyan); +} + +.knob-indicator { + position: absolute; + width: 4px; + height: 20px; + background: var(--cyan); + top: 8px; + left: 50%; + transform: translateX(-50%) rotate(0deg); + transform-origin: center 22px; + border-radius: 2px; + transition: transform 0.1s; +} + +.knob-control label { + font-size: 11px; + color: var(--text-dim); + text-transform: capitalize; +} + +/* Wave Section */ +.wave-section { + flex-shrink: 0; +} + +.wave-buttons { + display: flex; + flex-direction: column; + gap: 10px; +} + +.wave-btn { + padding: 10px 20px; + background: var(--bg-darker); + border: 2px solid var(--grid-line-thick); + color: var(--text-light); + font-size: 13px; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + text-transform: capitalize; +} + +.wave-btn:hover { + border-color: var(--cyan); + background: #1a1a1a; +} + +.wave-btn.active { + background: var(--cyan); + color: #000; + border-color: var(--cyan); +} + +/* Volume Section */ +.volume-section { + flex-shrink: 0; + align-items: center; +} + +.volume-slider-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; +} + +.vertical-slider { + writing-mode: vertical-lr; + direction: rtl; + width: 8px; + height: 120px; + background: var(--grid-line); + border-radius: 4px; + outline: none; + cursor: pointer; +} + +.vertical-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--cyan); + cursor: pointer; +} + +.vertical-slider::-moz-range-thumb { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--cyan); + cursor: pointer; + border: none; +} + +.volume-icon { + font-size: 24px; +} + +/* Responsive */ +@media (max-width: 1200px) { + #sequencer-grid { + grid-template-columns: 50px repeat(16, 35px); + } + + .grid-cell { + width: 35px; + height: 30px; + } + + .knobs-container { + gap: 20px; + } + + .knob { + width: 50px; + height: 50px; + } +} + +@media (max-width: 768px) { + .header { + flex-direction: column; + gap: 15px; + } + + #control-panel { + flex-direction: column; + gap: 25px; + } + + .knobs-container { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/examples/music-composer/python/main.py b/examples/music-composer/python/main.py new file mode 100644 index 0000000..d11f2c7 --- /dev/null +++ b/examples/music-composer/python/main.py @@ -0,0 +1,238 @@ +# SPDX-FileCopyrightText: Copyright (C) ARDUINO SRL (http://www.arduino.cc) +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.sound_generator import SoundGenerator, SoundEffect +from arduino.app_utils import App, Logger +import time +import logging + +logger = Logger("music-composer", logging.DEBUG) + +# Configuration +NOTES = ["B4", "A#4", "A4", "G#4", "G4", "F#4", "F4", "E4", "D#4", "D4", "C#4", "C4"] +GRID_STEPS = 16 + +# Sound generator brick +sound_gen = SoundGenerator(wave_form="sine", bpm=120, sound_effects=[SoundEffect.adsr()]) +sound_gen.start() # Start the audio output device +sound_gen.set_master_volume(0.8) # Set volume to 80% +logger.info("Sound generator initialized and started") + +# State +grid_state = {} # {noteIndex: {stepIndex: true/false}} +bpm = 120 +is_playing = False +current_step = 0 +last_step_time = 0 +effects_config = {"reverb": 0, "chorus": 0, "tremolo": 0, "vibrato": 0, "overdrive": 0} + +# Web UI +ui = WebUI() + + +def calculate_step_duration(bpm_value): + """Calculate duration of a single step in seconds based on BPM.""" + # Each step is a quarter note (1/4) + # 16 steps = 16 quarter notes = 4 bars in 4/4 time + beat_duration = 60.0 / bpm_value # seconds per beat (quarter note) + step_duration = beat_duration # 1 step = 1 beat = 1 quarter note + return step_duration + + +def user_loop(): + """Called repeatedly by App.run() - handles sequencer playback.""" + global current_step, last_step_time, is_playing + + if not is_playing: + return + + current_time = time.time() + step_duration = calculate_step_duration(bpm) + + # Check if it's time for the next step + if current_time - last_step_time >= step_duration: + # Collect notes to play at this step + notes_to_play = [] + for note_idx in range(len(NOTES)): + # Convert to string keys for grid access (JS sends strings) + note_key = str(note_idx) + step_key = str(current_step) + if note_key in grid_state and step_key in grid_state[note_key]: + if grid_state[note_key][step_key]: + notes_to_play.append(NOTES[note_idx]) + + # Play notes first (don't play REST, just silence) + if notes_to_play: + # Use play_tone with explicit duration in seconds + # Short duration (0.1 sec) to create staccato effect and avoid buffer issues + tone_duration = 0.1 # 100ms note + for note in notes_to_play: + try: + # Use block=True to force audio device to wait and not drop notes + sound_gen.play_tone(note, tone_duration, block=True) + logger.debug(f" -> Played {note} successfully") + except Exception as e: + logger.error(f"Error playing note {note}: {e}") + logger.debug(f"Step {current_step}: Playing {notes_to_play}") + else: + logger.debug(f"Step {current_step}: REST (silence)") + + # Notify frontend after starting sound playback + ui.send_message("composer:step_playing", {"step": current_step}) + + # Move to next step and maintain precise timing + current_step = (current_step + 1) % GRID_STEPS + # Use fixed step duration to avoid drift + last_step_time += step_duration + + +def on_connect(sid, data=None): + """Send current state when client connects.""" + logger.info(f"Client connected: {sid}") + state = {"grid": grid_state, "bpm": bpm, "effects": effects_config} + ui.send_message("composer:state", state, room=sid) + + +def on_get_state(sid, data=None): + """Client requests current state.""" + state = {"grid": grid_state, "bpm": bpm, "effects": effects_config} + ui.send_message("composer:state", state, room=sid) + + +def on_update_grid(sid, data=None): + """Update the grid state.""" + global grid_state + d = data or {} + if "grid" in d: + new_grid = d["grid"] + logger.debug(f"Grid update received: {new_grid}") + logger.debug(f"Grid before update: {grid_state}") + grid_state = new_grid + logger.debug(f"Grid after update: {grid_state}") + + +def on_play(sid, data=None): + """Start playing the sequencer loop.""" + global is_playing, current_step, last_step_time, grid_state, bpm + + if is_playing: + logger.warning("Play requested but already playing") + return + + d = data or {} + if "grid" in d: + grid_state = d["grid"] + logger.debug(f"Grid received in play: {grid_state}") + if "bpm" in d: + bpm = d["bpm"] + + is_playing = True + current_step = 0 + # Set last_step_time in the past so first step plays immediately + step_duration = calculate_step_duration(bpm) + last_step_time = time.time() - step_duration + logger.info(f"Started playback at {bpm} BPM, is_playing={is_playing}") + + +def on_stop(sid, data=None): + """Stop playing the sequencer loop.""" + global is_playing, current_step + logger.info(f"Stop requested, is_playing was {is_playing}") + is_playing = False + current_step = 0 + # Clear any playing step highlight + ui.send_message("composer:step_playing", {"step": -1}) + logger.info(f"Stopped playback, is_playing={is_playing}") + + +def on_set_bpm(sid, data=None): + """Set the BPM.""" + global bpm + d = data or {} + new_bpm = int(d.get("bpm", 120)) + bpm = max(40, min(240, new_bpm)) + sound_gen._bpm = bpm # Update sound generator BPM + logger.info(f"BPM set to: {bpm}") + + +def on_set_waveform(sid, data=None): + """Change the waveform type.""" + d = data or {} + waveform = d.get("waveform", "sine") + + valid_waveforms = ["sine", "square", "triangle", "sawtooth"] + if waveform in valid_waveforms: + sound_gen.set_wave_form(waveform) + logger.info(f"Waveform changed to: {waveform}") + + +def on_set_volume(sid, data=None): + """Set the playback volume.""" + d = data or {} + volume = int(d.get("volume", 80)) + volume = max(0, min(100, volume)) + + # Convert 0-100 to 0.0-1.0 + volume_float = volume / 100.0 + sound_gen.set_master_volume(volume_float) + logger.info(f"Volume set to: {volume}") + + +def on_set_effects(sid, data=None): + """Set the effects configuration.""" + global effects_config + d = data or {} + if "effects" in d: + effects_config = d["effects"] + logger.info(f"Effects updated: {effects_config}") + # Here you could apply effects to the sound generator + # For now, we just store the configuration + + +def on_export(sid, data=None): + """Export the composition as Arduino C/C++ code.""" + d = data or {} + export_grid = d.get("grid", grid_state) + + # Generate C array code + code_lines = ["// Generated by Music Composer", "// Grid: 16 steps x 12 notes", "", "const bool sequence[12][16] = {"] + + for note_idx in range(len(NOTES)): + row = [] + for step in range(GRID_STEPS): + has_note = note_idx in export_grid and step in export_grid[note_idx] and export_grid[note_idx][step] + row.append("1" if has_note else "0") + + row_str = " {" + ", ".join(row) + "}" + if note_idx < len(NOTES) - 1: + row_str += "," + code_lines.append(row_str + f" // {NOTES[note_idx]}") + + code_lines.append("};") + code_lines.append("") + code_lines.append(f"const int BPM = {bpm};") + code_lines.append(f'const char* notes[12] = {{"{'", "'.join(NOTES)}"}};') + + code_content = "\n".join(code_lines) + + ui.send_message("composer:export_data", {"content": code_content, "filename": "composition.h"}, room=sid) + + logger.info("Composition exported") + + +# Register event handlers +ui.on_connect(on_connect) +ui.on_message("composer:get_state", on_get_state) +ui.on_message("composer:update_grid", on_update_grid) +ui.on_message("composer:play", on_play) +ui.on_message("composer:stop", on_stop) +ui.on_message("composer:set_bpm", on_set_bpm) +ui.on_message("composer:set_waveform", on_set_waveform) +ui.on_message("composer:set_volume", on_set_volume) +ui.on_message("composer:set_effects", on_set_effects) +ui.on_message("composer:export", on_export) + +# Run the app with user loop +App.run(user_loop=user_loop)