diff --git a/examples/music-composer/README.md b/examples/music-composer/README.md new file mode 100644 index 0000000..ab80713 --- /dev/null +++ b/examples/music-composer/README.md @@ -0,0 +1,250 @@ +# Music Composer + +The **Music Composer** example provides a web-based step sequencer interface to create polyphonic music patterns using a grid-based note editor. It features 18 notes spanning from F#3 to B4, adjustable BPM, multiple waveforms, and a comprehensive effects rack, all powered by the Arduino Sound Generator brick. + +![Music Composer Example](assets/docs_assets/thumbnail.png) + +This App allows you to compose music by toggling notes on a grid where each row represents a note and each column represents a time step (eighth note). The grid dynamically expands as you add notes, supporting long compositions. Your creation can be played back in real-time with synchronized visual feedback, and you can export your composition as a Python file containing a `MusicComposition` object ready to be used in other Arduino App Lab projects. + +**Key features include:** + +- **Grid-based Step Sequencer:** 18-note polyphonic grid with automatic expansion. +- **Real-time Playback:** Visual step highlighting synchronized with audio playback. +- **Waveform Selection:** Choose from sine, square, and triangle waves. +- **Effects Rack:** Five knob-controlled effects (Bitcrusher, Chorus, Tremolo, Vibrato, Overdrive). +- **BPM Control:** Adjustable tempo(default 120 BPM). +- **Code Export:** Generate Python code with `MusicComposition` objects for reuse with `SoundGenerator` brick. + +## Bricks Used + +The Music Composer example uses the following Bricks: + +- `web_ui`: Brick that provides the web interface and WebSocket channel for real-time control. +- `sound_generator`: Brick that handles audio synthesis, effects processing, and step sequencer playback. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- **USB speaker** (cabled) +- **USB-C® hub with external power** (x1) *(required if using USB audio device)* +- A power supply (5 V, 3 A) for the USB hub (x1) *(required if using USB audio device)* + +### Software + +- Arduino App Lab + +**Note:** A **USB-C® hub is mandatory** if you want to use an external USB audio device. The UNO Q's single port must be used for the hub, which provides the necessary connections for both the power supply and the USB audio device. Consequently, when using external audio, this example must be run in **[Network Mode](learn/network-mode)** or **[SBC Mode](learn/single-board-computer)**. + +## How to Use the Example + +1. **Hardware Setup (Optional External Audio)** + If you want to use an external USB audio device, connect it to a powered **USB-C® hub** attached to the UNO Q. Ensure the hub is powered. + +2. **Run the App** + Launch the App from Arduino App Lab. Wait until the App has launched completely. + +3. **Access the Web Interface** + Open the App in your browser at `:7000` (typically `192.168.x.x`). + +4. **Create a Pattern** + - **Toggle Notes:** Click cells in the grid to activate or deactivate notes. Active cells turn green. + - **Notes:** Each row corresponds to a specific pitch (B4 at the top, F#3 at the bottom). + - **Steps:** Each column represents an eighth note (1/8 beat). + - **Grid Expansion:** The grid automatically expands by 16 steps when you add notes near the right edge. + +5. **Adjust BPM** + Use the BPM input field in the header to set the tempo (40-240 BPM). Click the **↻** button to reset to 120 BPM. + +6. **Select a Waveform** + Choose a waveform by clicking one of the wave buttons in the control panel: + - **Sine:** Smooth, pure tone + - **Square:** Classic synth sound, retro game style + - **Triangle:** Mellower, softer than square + +7. **Apply Effects** + Adjust the five effect knobs by clicking and dragging vertically: + - **Bitcrusher:** Lowers bit depth for lo-fi digital distortion. + - **Chorus:** Adds depth and richness by simulating multiple voices. + - **Tremolo:** Rhythmic amplitude modulation (volume vibrato). + - **Vibrato:** Pitch modulation for expressive warble. + - **Overdrive:** Adds harmonic distortion and saturation. + +8. **Play Your Composition** + Click the **▶ Play** button in the header. The current step will be highlighted with a cyan border as the sequence plays. Click **⏸ Pause** or **⏹ Stop** to control playback. + +9. **Clear or Export** + - **Clear:** Click the **✕** button to clear all notes. + - **Export:** Click **Export .h** to download a Python file containing your composition as a `MusicComposition` object. + +## How it Works + +The application relies on a synchronized data flow between the web interface and the audio synthesis engine. + +**High-level data flow:** + +``` +Web Browser Interaction ──► WebSocket ──► Python Backend + ▲ │ + │ ▼ + (Visual Updates) (Build Sequence from Grid) + │ │ + └─ WebSocket ◄── State ◄── SoundGenerator Brick + │ + ▼ + Audio Output (USB) +``` + +- **User Interaction:** The frontend captures clicks on the grid and sends the updated grid state to the backend via WebSocket (`composer:update_grid`). +- **Sequence Building:** The Python backend converts the 2D grid (notes × steps) into a polyphonic sequence format: a list of steps, where each step contains a list of notes to play simultaneously. +- **Audio Playback:** The `SoundGenerator` brick's `play_step_sequence()` method plays the sequence, calling the `on_step_callback` for each step to synchronize visual feedback. +- **Step Highlighting:** The frontend runs a local timer to highlight the current step in sync with the backend-driven audio playback. + +## Understanding the Code + +### 🔧 Backend (`main.py`) + +The Python script orchestrates the grid-to-audio conversion and manages the state. + +- **Initialization:** + - `gen = SoundGenerator(wave_form="square", bpm=120, sound_effects=[SoundEffect.adsr()])`: Initializes the audio engine with a square wave and ADSR envelope. + - `NOTE_MAP`: A list of 18 note names from B4 down to F#3, corresponding to grid rows. + +- **Grid State Management:** + The grid state is stored as a nested dictionary: `{"noteIndex": {"stepIndex": bool}}`. For example, `grid["0"]["5"] = True` means the note B4 (row 0) is active on step 5. + +- **Sequence Building:** + The `build_sequence_from_grid()` function converts the grid dictionary into a list of steps: + + ```python + sequence = [ + ["C4", "E4"], # Step 0: play C4 and E4 together + [], # Step 1: rest (no notes) + ["G4"], # Step 2: play G4 + # ... + ] + ``` + +- **Event Handlers:** + - `on_update_grid`: Receives the grid state from the frontend and broadcasts it to all clients. + - `on_play`: Builds the sequence from the grid and calls `gen.play_step_sequence()` to start playback. + - `on_stop`: Stops the sequence playback. + - `on_set_bpm`, `on_set_waveform`, `on_set_volume`, `on_set_effects`: Update the audio parameters. + - `on_export`: Generates a Python file containing the composition as a `MusicComposition` object. + +- **Step Callback:** + The `on_step_callback` is invoked by the `SoundGenerator` brick for each step. It sends a `composer:step_playing` event to the frontend for synchronization (though the frontend uses its own timer for smoother animation). + + ```python + def on_step_callback(step: int, total_steps: int): + current_step = step + ui.send_message("composer:step_playing", {"step": step, "total_steps": total_steps}) + ``` + +### 🔧 Frontend (`app.js`) + +The JavaScript frontend handles the UI logic, grid rendering, and playback visualization. + +- **Grid Rendering:** + The `buildGrid()` function dynamically creates the grid based on `totalSteps` (initially 16, expands by 16 when needed). Each cell has `data-note` and `data-step` attributes for easy lookup. + +- **Toggle Cell:** + Clicking a cell toggles its state in the local `grid` object and emits the updated grid to the backend: + + ```javascript + function toggleCell(noteIndex, step) { + const noteKey = String(noteIndex); + const stepKey = String(step); + if (!grid[noteKey]) grid[noteKey] = {}; + const newValue = !(grid[noteKey][stepKey] === true); + grid[noteKey][stepKey] = newValue; + renderGrid(); + socket.emit('composer:update_grid', { grid }); + } + ``` + +- **Playback Animation:** + When the **Play** button is clicked, the frontend starts a local interval timer that highlights the current step at the rate determined by BPM: + + ```javascript + function startLocalPlayback() { + const stepDurationMs = (60000 / bpm) / 2; // Eighth notes: 2 per beat + playInterval = setInterval(() => { + currentStep++; + if (currentStep >= effectiveLength) { + stopLocalPlayback(); + return; + } + highlightStep(currentStep); + }, stepDurationMs); + } + ``` + + This ensures smooth visual feedback even if network latency varies. + +- **Effects Knobs:** + The knobs use mouse drag to adjust values (0-100). Dragging up increases the value, dragging down decreases it. On `mouseup`, the new effects state is sent to the backend. + +- **Auto-scroll:** + The sequencer container auto-scrolls horizontally to keep the currently playing step visible in the center of the viewport. + +### 🔧 Sequence Export + +The `on_export` handler generates Python code that defines a `MusicComposition` object. This object can be loaded and played in other Arduino App Lab projects using `gen.play_composition(composition)`. + +**Example exported code:** + +```python +from arduino.app_bricks.sound_generator import MusicComposition, SoundEffect + +composition = MusicComposition( + composition=[ + [("C4", 0.125), ("E4", 0.125), ...], # Track 1 + [("G4", 0.125), ("REST", 0.125), ...], # Track 2 + ], + bpm=120, + waveform="square", + volume=0.80, + effects=[ + SoundEffect.adsr(), + SoundEffect.chorus(depth_ms=10, rate_hz=0.25, mix=0.40) + ] +) +``` + +## Troubleshooting + +### "No USB speaker found" error (when using external audio) +If the application fails to start and you see an error regarding the speaker: +**Fix:** +1. Ensure a **powered USB-C® hub** is connected to the UNO Q. +2. Verify the **USB audio device** is connected to the hub and turned on. +3. Restart the application. + +### No Sound Output +If the interface works but there is no sound: +- **Volume Control:** Check the volume slider in the UI (right side of control panel). +- **System Volume:** Ensure your speaker or system volume is not muted. +- **Grid Empty:** Ensure you have toggled at least one note cell (it should be green). +- **Audio Device:** Remember that **HDMI audio** and **Bluetooth® speakers** are not supported. + +### Choppy or Crackling Audio +- **CPU Load:** Close other applications running on the Arduino UNO Q. +- **Power Supply:** Ensure you are using a stable 5 V, 3 A power supply for the USB-C® hub. Insufficient power often degrades USB audio performance. + +### Grid Not Expanding +- The grid expands automatically when you click a note within 8 steps of the right edge. Ensure you are clicking far enough to the right. + +### Playback Not Highlighting Correctly +- The frontend uses a local timer to highlight steps. If the BPM changes during playback, stop and restart playback to sync the timer. + +## Technical Details + +- **Grid Size:** 18 notes (F#3 to B4) × dynamic steps (initially 16, expands by 16) +- **Note Duration:** Eighth notes (1/8 beat) +- **BPM Range:** 40-240 BPM (default: 120) +- **Waveforms:** Sine, Square, Triangle +- **Effects:** Bitcrusher, Chorus, Tremolo, Vibrato, Overdrive +- **Export Format:** Python file with `MusicComposition` object +- **Audio Output:** USB audio device diff --git a/examples/music-composer/app.yaml b/examples/music-composer/app.yaml new file mode 100644 index 0000000..086ef0d --- /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 and play them using sound generator brick. +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..4fe32a7 --- /dev/null +++ b/examples/music-composer/assets/app.js @@ -0,0 +1,440 @@ +/* + * 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 INITIAL_GRID_STEPS = 16; // Initial visible steps + const NOTES = ['B4', 'A#4', 'A4', 'G#4', 'G4', 'F#4', 'F4', 'E4', 'D#4', 'D4', 'C#4', 'C4', 'B3', 'A#3', 'A3', 'G#3', 'G3', 'F#3']; + const STEPS_PER_EXPAND = 16; // Add 16 steps when scrolling + + // State + let grid = null; // {noteIndex: {stepIndex: true/false}} - null until server sends state + let isPlaying = false; + let isPaused = false; + let currentStep = 0; + let totalSteps = INITIAL_GRID_STEPS; // Dynamic grid size + let sequenceLength = INITIAL_GRID_STEPS; // Actual sequence length from backend + let bpm = 120; + let playInterval = null; + let effects = { + bitcrusher: 0, + chorus: 0, + tremolo: 0, + vibrato: 0, + overdrive: 0 + }; + + // DOM elements + const playBtn = document.getElementById('play-btn'); + const pauseBtn = document.getElementById('pause-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); + } + if (data.current_step !== undefined) { + currentStep = data.current_step; + log.debug('Current step synced:', currentStep); + } + if (data.total_steps !== undefined) { + sequenceLength = data.total_steps; + log.info('Sequence length from backend:', sequenceLength); + } + renderGrid(); + updateEffectsKnobs(); + }); + + socket.on('composer:step_playing', (data) => { + // Backend callback - used only for synchronization check, not for UI updates + log.debug('Backend step playing:', data.step, '(frontend is handling UI timing locally)'); + }); + + socket.on('composer:playback_ended', () => { + // Backend signals sequence generation complete (but audio still in queue) + // Don't stop UI animation - it runs on its own timer until effectiveLength + log.info('Backend sequence generation complete (audio still playing from queue)'); + }); + + socket.on('composer:export_data', (data) => { + log.info('Export data received'); + 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); + }); + + // Build grid with dynamic size + 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 < totalSteps; 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 < totalSteps; 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 < totalSteps - 1) { + cell.classList.add('beat-separator'); + } + + cell.addEventListener('click', () => toggleCell(noteIndex, step)); + sequencerGrid.appendChild(cell); + } + }); + + // Update grid CSS for dynamic columns + sequencerGrid.style.gridTemplateColumns = `auto repeat(${totalSteps}, 40px)`; + } + + 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)); + + // Expand grid if clicking near the end + if (newValue) { + expandGridIfNeeded(); + } + + 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); + }); + + // Auto-scroll to keep current step visible + if (step >= 0) { + const container = document.getElementById('sequencer-container'); + const cellWidth = 40; // Width of one grid cell + const targetScroll = (step * cellWidth) - (container.clientWidth / 2); + container.scrollLeft = Math.max(0, targetScroll); + } + } + + function findLastNoteStep() { + // Find the highest step index that has at least one note + let lastStep = -1; + if (grid) { + Object.keys(grid).forEach(noteKey => { + Object.keys(grid[noteKey]).forEach(stepKey => { + if (grid[noteKey][stepKey]) { + const stepNum = parseInt(stepKey); + console.log(`Note ${noteKey} at step ${stepNum}`); + if (stepNum > lastStep) { + lastStep = stepNum; + } + } + }); + }); + } + console.log(`findLastNoteStep returned: ${lastStep}`); + return lastStep; + } + + function expandGridIfNeeded() { + const lastNote = findLastNoteStep(); + // Expand if we're within 8 steps of the edge + if (lastNote >= totalSteps - 8) { + totalSteps += STEPS_PER_EXPAND; + buildGrid(); + renderGrid(); + log.info('Grid expanded to', totalSteps, 'steps'); + } + } + + function startLocalPlayback() { + // Calculate sequence length: find last note step, minimum 16 + const lastNoteStep = findLastNoteStep(); + const effectiveLength = lastNoteStep >= 0 ? Math.max(lastNoteStep + 1, 16) : 16; + + console.log('=== PLAYBACK START ==='); + console.log('Grid:', grid); + console.log('Last note step:', lastNoteStep); + console.log('Effective length:', effectiveLength); + console.log('BPM:', bpm); + + // Calculate step duration in milliseconds + const stepDurationMs = (60000 / bpm) / 2; // Eighth notes: 2 per beat + + currentStep = 0; + highlightStep(currentStep); + + playInterval = setInterval(() => { + currentStep++; + console.log(`Step ${currentStep}/${effectiveLength}`); + if (currentStep >= effectiveLength) { + // Sequence ended + console.log('Playback ended at step', currentStep); + stopLocalPlayback(); + return; + } + highlightStep(currentStep); + log.debug('Frontend step:', currentStep); + }, stepDurationMs); + + log.info('Local playback started:', stepDurationMs, 'ms per step, will play', effectiveLength, 'steps'); + } + + function stopLocalPlayback() { + if (playInterval) { + clearInterval(playInterval); + playInterval = null; + } + isPlaying = false; + isPaused = false; + playBtn.style.display = 'flex'; + pauseBtn.style.display = 'none'; + stopBtn.style.display = 'none'; + highlightStep(-1); + } + + // Play button - starts from beginning or resumes from pause + playBtn.addEventListener('click', () => { + isPlaying = true; + playBtn.style.display = 'none'; + pauseBtn.style.display = 'flex'; + stopBtn.style.display = 'flex'; + log.info(isPaused ? 'Resuming playback' : 'Starting playback at', bpm, 'BPM'); + + // Start local UI animation immediately + startLocalPlayback(); + + // Trigger backend audio playback + socket.emit('composer:play', { grid, bpm }); + }); + + // Pause button - for infinite loop we only have stop (pause not supported with loop=True) + pauseBtn.addEventListener('click', () => { + stopLocalPlayback(); + log.info('Stopping playback (pause not supported in infinite loop mode)'); + socket.emit('composer:stop', {}); + }); + + // Stop button - resets to beginning, clears highlight + stopBtn.addEventListener('click', () => { + stopLocalPlayback(); + log.info('Stopping playback'); + socket.emit('composer:stop', {}); + }); + + // 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 }); + }); + + // 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..580c2eb --- /dev/null +++ b/examples/music-composer/assets/index.html @@ -0,0 +1,121 @@ + + + + + + + + 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..819c61a --- /dev/null +++ b/examples/music-composer/assets/style.css @@ -0,0 +1,497 @@ +/* + * 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; +} + +.pause-button { + width: 50px; + height: 50px; + border-radius: 50%; + background: #ffaa00; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.pause-button:hover { + background: #dd9900; + transform: scale(1.05); +} + +.pause-icon { + font-size: 20px; + color: #000; +} + +.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-x: auto; /* Horizontal scroll for infinite grid */ + overflow-y: auto; + display: flex; + justify-content: flex-start; /* Align left for scroll */ + align-items: flex-start; +} + +#sequencer-grid { + display: grid; + grid-template-columns: 60px repeat(16, 40px); /* Will be updated dynamically by JS */ + gap: 0; + background: var(--bg-dark); + min-width: min-content; /* Allow grid to expand beyond viewport */ + 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..34c9ad0 --- /dev/null +++ b/examples/music-composer/python/main.py @@ -0,0 +1,372 @@ +# 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 logging + +logger = Logger(__name__, logging.DEBUG) + +# Components +ui = WebUI() +gen = SoundGenerator(wave_form="square", bpm=120, sound_effects=[SoundEffect.adsr()]) +gen.start() +gen.set_master_volume(0.8) + +# Note map (18 notes from B4 down to F#3) +NOTE_MAP = ["B4", "A#4", "A4", "G#4", "G4", "F#4", "F4", "E4", "D#4", "D4", "C#4", "C4", "B3", "A#3", "A3", "G#3", "G3", "F#3"] + +# State +grid_state = {} # {"noteIdx": {"stepIdx": bool}} +bpm = 120 +is_playing = False +current_step = 0 +waveform = "sine" +volume = 0.8 +effects_state = {} + + +def on_step_callback(step: int, total_steps: int): + """Called by SoundGenerator for each step - synchronized with audio.""" + global current_step + current_step = step + ui.send_message("composer:step_playing", {"step": step, "total_steps": total_steps}) + + +def on_sequence_complete(): + """Called when sequence playback completes.""" + global is_playing, current_step + is_playing = False + current_step = 0 + logger.info("Sequence completed") + ui.send_message("composer:playback_ended", {}) + + +def build_sequence_from_grid(grid: dict) -> list[list[str]]: + """Build sequence from grid state. + + Args: + grid: Grid state dictionary + + Returns: + List of steps, each step is list of notes (or empty for rest). + """ + # Find max step index with notes + max_step = -1 + for note_key in grid: + for step_key in grid[note_key]: + if grid[note_key][step_key]: + max_step = max(max_step, int(step_key)) + + # Build sequence up to last note (minimum 16 steps) + length = max(max_step + 1, 16) if max_step >= 0 else 16 + + sequence = [] + for step in range(length): + step_notes = [] + for note_idx in range(len(NOTE_MAP)): + note_key = str(note_idx) + step_key = str(step) + if note_key in grid and step_key in grid[note_key] and grid[note_key][step_key]: + step_notes.append(NOTE_MAP[note_idx]) + sequence.append(step_notes) + + return sequence + + +def on_connect(sid, data=None): + """Send initial state to new client.""" + logger.info(f"Client connected: {sid}") + ui.send_message( + "composer:state", + {"grid": grid_state, "bpm": bpm, "is_playing": is_playing, "current_step": current_step}, + room=sid, + ) + + +def on_get_state(sid, data=None): + """Send current state.""" + ui.send_message( + "composer:state", + {"grid": grid_state, "bpm": bpm, "is_playing": is_playing, "current_step": current_step}, + room=sid, + ) + + +def on_update_grid(sid, data=None): + """Update grid state.""" + global grid_state + + if is_playing: + logger.warning("Grid update rejected: playback in progress") + return + + grid_state = data.get("grid", {}) + logger.debug("Grid updated") + + ui.send_message( + "composer:state", + { + "grid": grid_state, + "bpm": bpm, + "is_playing": is_playing, + "current_step": current_step, + }, + ) + + +def on_set_bpm(sid, data=None): + """Update BPM.""" + global bpm + + if is_playing: + logger.warning("BPM update rejected: playback in progress") + return + + if data: + bpm = data.get("bpm", 120) + gen.set_bpm(bpm) # Update the brick's internal BPM + logger.info(f"BPM updated to {bpm}") + else: + logger.warning("No BPM data received") + + ui.send_message( + "composer:state", + { + "grid": grid_state, + "bpm": bpm, + "is_playing": is_playing, + "current_step": current_step, + }, + ) + + +def on_play(sid, data=None): + """Start playback.""" + global is_playing + + if is_playing: + logger.warning("Already playing") + return + + # Build sequence from grid + sequence = build_sequence_from_grid(grid_state) + logger.info(f"Starting playback: {len(sequence)} steps at {bpm} BPM") + + # Start playback (one-shot, will loop automatically when it finishes) + is_playing = True + gen.play_step_sequence( + sequence=sequence, + note_duration=1 / 8, + loop=False, # One-shot playback + on_step_callback=on_step_callback, + on_complete_callback=on_sequence_complete, + ) + + ui.send_message( + "composer:state", + { + "grid": grid_state, + "bpm": bpm, + "is_playing": is_playing, + "current_step": current_step, + "total_steps": len(sequence), # Tell frontend how many steps + }, + ) + + +def on_stop(sid, data=None): + """Stop playback.""" + global is_playing, current_step + + logger.info("on_stop called") + if not is_playing: + logger.warning("Stop called but already not playing") + return + + logger.info("Calling gen.stop_sequence()") + gen.stop_sequence() + is_playing = False + current_step = 0 + logger.info("Playback stopped") + + ui.send_message( + "composer:state", + { + "grid": grid_state, + "bpm": bpm, + "is_playing": is_playing, + "current_step": current_step, + }, + ) + + +def on_set_waveform(sid, data=None): + """Change waveform.""" + global waveform + waveform = data.get("waveform", "sine") + if waveform in ["sine", "square", "triangle", "sawtooth"]: + gen.set_wave_form(waveform) + logger.info(f"Waveform: {waveform}") + + +def on_set_volume(sid, data=None): + """Change volume.""" + global volume + volume = data.get("volume", 80) / 100.0 + gen.set_master_volume(volume) + logger.info(f"Volume: {volume:.2f}") + + +def on_set_effects(sid, data=None): + """Update effects.""" + global effects_state + effects_state = data.get("effects", {}) + effect_list = [SoundEffect.adsr()] + + if effects_state.get("bitcrusher", 0) > 0: + bits = int(8 - (effects_state["bitcrusher"] / 100.0) * 6) + effect_list.append(SoundEffect.bitcrusher(bits=bits, reduction=4)) + + if effects_state.get("chorus", 0) > 0: + level = effects_state["chorus"] / 100.0 + effect_list.append(SoundEffect.chorus(depth_ms=int(5 + level * 20), rate_hz=0.25, mix=level * 0.8)) + + if effects_state.get("tremolo", 0) > 0: + level = effects_state["tremolo"] / 100.0 + effect_list.append(SoundEffect.tremolo(depth=level, rate=5.0)) + + if effects_state.get("vibrato", 0) > 0: + level = effects_state["vibrato"] / 100.0 + effect_list.append(SoundEffect.vibrato(depth=level * 0.05, rate=2.0)) + + if effects_state.get("overdrive", 0) > 0: + level = effects_state["overdrive"] / 100.0 + effect_list.append(SoundEffect.overdrive(drive=1.0 + level * 200)) + + gen.set_effects(effect_list) + logger.info(f"Effects applied: {len(effect_list)}") + + +def on_export(sid, data=None): + """Export a MusicComposition object to a Python file.""" + sequence = build_sequence_from_grid(grid_state) + + # Build polyphonic sequences: one track per note + tracks = [] + for note_idx, note_name in enumerate(NOTE_MAP): + track = [] + for step_notes in sequence: + if step_notes and note_name in step_notes: + # Note is active in this step + track.append((note_name, 1 / 8)) # Duration: 1/8 note (eighth note) + elif track and track[-1][0] != "REST": + # Add REST if previous was not a REST + track.append(("REST", 1 / 8)) + elif not track: + # Start with REST if note doesn't start immediately + track.append(("REST", 1 / 8)) + + # Only include tracks that have actual notes (not just REST) + if any(note != "REST" for note, _ in track): + tracks.append(track) + + # Build effects list code representation + effects_code = ["SoundEffect.adsr()"] + + if effects_state.get("bitcrusher", 0) > 0: + bits = int(8 - (effects_state["bitcrusher"] / 100.0) * 6) + effects_code.append(f"SoundEffect.bitcrusher(bits={bits}, reduction=4)") + + if effects_state.get("chorus", 0) > 0: + level = effects_state["chorus"] / 100.0 + depth_ms = int(5 + level * 20) + mix = level * 0.8 + effects_code.append(f"SoundEffect.chorus(depth_ms={depth_ms}, rate_hz=0.25, mix={mix:.2f})") + + if effects_state.get("tremolo", 0) > 0: + level = effects_state["tremolo"] / 100.0 + effects_code.append(f"SoundEffect.tremolo(depth={level:.2f}, rate=5.0)") + + if effects_state.get("vibrato", 0) > 0: + level = effects_state["vibrato"] / 100.0 + depth = level * 0.05 + effects_code.append(f"SoundEffect.vibrato(depth={depth:.4f}, rate=2.0)") + + if effects_state.get("overdrive", 0) > 0: + level = effects_state["overdrive"] / 100.0 + drive = 1.0 + level * 200 + effects_code.append(f"SoundEffect.overdrive(drive={drive:.2f})") + + # Generate Python code with MusicComposition + code_lines = [ + "# Music Composer - Generated Composition", + "# This file contains a MusicComposition object that can be played with SoundGenerator.play_composition()", + "", + "from arduino.app_bricks.sound_generator import MusicComposition, SoundEffect", + "", + f"# Configuration: {len(sequence)} steps at {bpm} BPM", + "", + "# Define the composition tracks", + "composition_tracks = [", + ] + + # Add tracks + for i, track in enumerate(tracks): + code_lines.append(" [ # Track " + str(i + 1)) + for j, (note, duration) in enumerate(track): + duration_str = f"{duration:.3f}" + comma = "," if j < len(track) - 1 else "" + code_lines.append(f' ("{note}", {duration_str}){comma}') + comma = "," if i < len(tracks) - 1 else "" + code_lines.append(" ]" + comma) + + code_lines.extend([ + "]", + "", + "# Create the MusicComposition object", + "composition = MusicComposition(", + " composition=composition_tracks,", + f" bpm={bpm},", + f' waveform="{waveform}",', + f" volume={volume:.2f},", + " effects=[", + ]) + + # Add effects + for i, effect in enumerate(effects_code): + comma = "," if i < len(effects_code) - 1 else "" + code_lines.append(f" {effect}{comma}") + + code_lines.extend([ + " ]", + ")", + ]) + + ui.send_message( + "composer:export_data", + { + "filename": "composition.py", + "content": "\n".join(code_lines), + }, + room=sid, + ) + + +# Register all 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:set_bpm", on_set_bpm) +ui.on_message("composer:play", on_play) +ui.on_message("composer:stop", on_stop) +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) + + +if __name__ == "__main__": + App.run()