diff --git a/jest.config.cjs b/jest.config.cjs index 086325f21..60700b8f5 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -1,10 +1,18 @@ /** @type {import('jest').Config} */ -// Check if better-sqlite3 native bindings are available +const { readdirSync, readFileSync, statSync } = require('fs'); +const { join } = require('path'); + +// Check if better-sqlite3 native bindings are available. The `midi` native +// module (and better-sqlite3) are often absent in containers/CI where the +// project is installed with `npm install --ignore-scripts` (see CLAUDE.md), so +// the SQLite-backed suites must be skipped rather than fail with a bindings +// error. let hasBetterSqlite = false; try { const Database = require('better-sqlite3'); - // Actually try to create an in-memory database + // Actually try to create an in-memory database — requiring the module alone + // does NOT load the native binding; only instantiating a Database does. const db = new Database(':memory:'); db.close(); hasBetterSqlite = true; @@ -12,10 +20,41 @@ try { // Native bindings not compiled } +// A suite genuinely needs the native SQLite bindings when it imports +// `better-sqlite3` directly, constructs the top-level persistence +// Database/DatabaseManager (which connects in its constructor), or runs the +// migration runner. Detecting this from the test source keeps the skip list +// self-maintaining: new SQLite-backed suites are picked up automatically +// instead of silently failing once the hard-coded list drifts. +const NEEDS_SQLITE = /better-sqlite3|\bnew DatabaseManager\s*\(|new Database\s*\(\s*\{|runMigrations\s*\(/; + +function collectSqliteSuites(dir, acc = []) { + let entries; + try { + entries = readdirSync(dir); + } catch { + return acc; + } + for (const entry of entries) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + if (entry !== 'frontend') collectSqliteSuites(full, acc); + } else if (entry.endsWith('.test.js')) { + if (NEEDS_SQLITE.test(readFileSync(full, 'utf8'))) acc.push(full); + } + } + return acc; +} + +// `audit-i18n.test.js` uses the Vitest API (it is run by the frontend Vitest +// project, see vitest.config.js), so it must never be collected by Jest. const ignorePatterns = ['/node_modules/', '/tests/frontend/', '/tests/audit-i18n.test.js']; if (!hasBetterSqlite) { - ignorePatterns.push('/tests/midi-filter.test.js'); + for (const suite of collectSqliteSuites(join(__dirname, 'tests'))) { + // Anchor on the repo-relative path so only this exact file is ignored. + ignorePatterns.push(suite.slice(__dirname.length).replace(/\\/g, '/')); + } } module.exports = { diff --git a/package-lock.json b/package-lock.json index b9cbe86b2..193283282 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8939,9 +8939,9 @@ "license": "MIT" }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "license": "BlueOak-1.0.0", "optional": true, "dependencies": { diff --git a/package.json b/package.json index da8ff48f4..82f602ae8 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "node": ">=20.0.0" }, "overrides": { - "node-gyp": ">=10.0.0" + "node-gyp": ">=10.0.0", + "tar": ">=7.5.21" } } diff --git a/public/js/api/BackendAPIClient.js b/public/js/api/BackendAPIClient.js index b4a271711..d7b5335f2 100644 --- a/public/js/api/BackendAPIClient.js +++ b/public/js/api/BackendAPIClient.js @@ -138,6 +138,15 @@ class BackendAPIClient { if (wasConnected) { this.emit('disconnected'); } + // Clear the in-flight reconnect guard before rescheduling. When a + // reconnect attempt's socket fails asynchronously, `onerror` does not + // reject (it is suppressed while `_reconnecting` is true) and this + // `connect()` promise never settles, so the timer's `.catch` that + // would reset `_reconnecting` never runs. Without resetting it here, + // `attemptReconnect()` early-returns and the retry loop dies after a + // single attempt — contradicting the "retries indefinitely" contract + // and requiring a manual page reload to recover. + this._reconnecting = false; this.attemptReconnect(); }; @@ -257,7 +266,10 @@ class BackendAPIClient { if (message.command !== undefined) err.command = message.command; pending.reject(err); } else { - pending.resolve(message.data || message); + // Use presence, not truthiness: a handler that legitimately returns + // falsy `data` (0, false, '', null) must not leak the raw protocol + // envelope ({ id, data, timestamp, … }) to the caller. + pending.resolve('data' in message ? message.data : message); } return; } diff --git a/public/js/audio/MidiSynthesizer.js b/public/js/audio/MidiSynthesizer.js index f5e59b04a..e3a5d5ca5 100644 --- a/public/js/audio/MidiSynthesizer.js +++ b/public/js/audio/MidiSynthesizer.js @@ -1278,8 +1278,8 @@ class MidiSynthesizer { // fully-lazy mode while still saving ~half the eager requests. if (usedNotes.size === 0) { const COMMON_DRUMS = [ - 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, - 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59 + 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, + 58, 59 ]; for (const n of COMMON_DRUMS) usedNotes.add(n); } diff --git a/public/js/audio/MidiSynthesizerConstants.js b/public/js/audio/MidiSynthesizerConstants.js index 6d2c945f9..c8f8ed44a 100644 --- a/public/js/audio/MidiSynthesizerConstants.js +++ b/public/js/audio/MidiSynthesizerConstants.js @@ -3,7 +3,7 @@ // Exposed on `window.MidiSynthesizerConstants` because the codebase uses // IIFE+globals (no ES modules in /public/js). -(function() { +(function () { 'use strict'; /** @@ -27,7 +27,7 @@ reverbMix: 0.12, isBuiltInSF2: true, sf2Id: 'default', - drumKits: [0, 8, 16, 24, 25, 32, 40, 48, 56].map(function(p) { + drumKits: [0, 8, 16, 24, 25, 32, 40, 48, 56].map(function (p) { return { midiProgram: p, bankIndex: p, verified: true }; }) }; @@ -60,15 +60,20 @@ // any feature the rest of the codebase depends on. const WAF_BANKS = [ { - id: 'FluidR3_GM', label: 'FluidR3 GM', suffix: 'FluidR3_GM_sf2_file', - quality: 'high', sizeMB: 141, descKey: 'settings.soundBank.banks.FluidR3_GM', reverbMix: 0.08, + id: 'FluidR3_GM', + label: 'FluidR3 GM', + suffix: 'FluidR3_GM_sf2_file', + quality: 'high', + sizeMB: 141, + descKey: 'settings.soundBank.banks.FluidR3_GM', + reverbMix: 0.08, requiresExternal: true, drumKits: [ - { midiProgram: 0, bankIndex: 0, verified: true }, - { midiProgram: 8, bankIndex: 8, verified: true }, - { midiProgram: 16, bankIndex: 16, verified: true }, - { midiProgram: 24, bankIndex: 24, verified: true }, - { midiProgram: 25, bankIndex: 25, verified: true }, + { midiProgram: 0, bankIndex: 0, verified: true }, + { midiProgram: 8, bankIndex: 8, verified: true }, + { midiProgram: 16, bankIndex: 16, verified: true }, + { midiProgram: 24, bankIndex: 24, verified: true }, + { midiProgram: 25, bankIndex: 25, verified: true }, { midiProgram: 32, bankIndex: 32, verified: false }, { midiProgram: 40, bankIndex: 40, verified: false }, { midiProgram: 48, bankIndex: 48, verified: false }, @@ -76,8 +81,13 @@ ] }, { - id: 'JCLive', label: 'JCLive', suffix: 'JCLive_sf2_file', - quality: 'medium', sizeMB: 26, descKey: 'settings.soundBank.banks.JCLive', reverbMix: 0.10, + id: 'JCLive', + label: 'JCLive', + suffix: 'JCLive_sf2_file', + quality: 'medium', + sizeMB: 26, + descKey: 'settings.soundBank.banks.JCLive', + reverbMix: 0.1, requiresExternal: true, drumKits: [{ midiProgram: 0, bankIndex: 12, verified: true }] } @@ -92,19 +102,19 @@ let _customBanks = []; function setCustomBanks(banks) { - _customBanks = (banks || []).map(function(b) { + _customBanks = (banks || []).map(function (b) { return { - id: 'sf2:' + b.id, - label: b.label + ' [SF2]', - suffix: null, - quality: 'custom', - sizeMB: Math.round((b.size || 0) / (1024 * 1024)), - reverbMix: b.reverbMix != null ? b.reverbMix : 0.12, - isCustom: true, - sf2Id: b.id, - drumKits: [0, 8, 16, 24, 25, 32, 40, 48, 56].map(function(p) { + id: 'sf2:' + b.id, + label: b.label + ' [SF2]', + suffix: null, + quality: 'custom', + sizeMB: Math.round((b.size || 0) / (1024 * 1024)), + reverbMix: b.reverbMix != null ? b.reverbMix : 0.12, + isCustom: true, + sf2Id: b.id, + drumKits: [0, 8, 16, 24, 25, 32, 40, 48, 56].map(function (p) { return { midiProgram: p, bankIndex: p, verified: false }; - }), + }) }; }); } @@ -131,13 +141,21 @@ // for backwards compatibility (InstrumentSettingsModal still reads it), // but it now resolves to the gated list — never the legacy WAF banks. get SOUND_BANKS() { - return Object.freeze([BUILT_IN_DEFAULT_SF2_BANK].map(function(b) { return Object.freeze(b); })); + return Object.freeze( + [BUILT_IN_DEFAULT_SF2_BANK].map(function (b) { + return Object.freeze(b); + }) + ); }, BUILT_IN_DEFAULT_SF2_BANK: Object.freeze(BUILT_IN_DEFAULT_SF2_BANK), - WAF_BANKS: Object.freeze(WAF_BANKS.map(function(b) { return Object.freeze(b); })), + WAF_BANKS: Object.freeze( + WAF_BANKS.map(function (b) { + return Object.freeze(b); + }) + ), DEFAULT_BANK_ID, DEFAULT_BANK_SUFFIX, setCustomBanks, - getAvailableBanks, + getAvailableBanks }; })(); diff --git a/public/js/audio/MidiSynthesizerTempoMap.js b/public/js/audio/MidiSynthesizerTempoMap.js index 9a6a24603..77a97eb68 100644 --- a/public/js/audio/MidiSynthesizerTempoMap.js +++ b/public/js/audio/MidiSynthesizerTempoMap.js @@ -3,7 +3,7 @@ // from MidiSynthesizer.js (P2-F.8b). // Exposed on `window.MidiSynthesizerTempoMap` (IIFE+globals convention). -(function() { +(function () { 'use strict'; /** @@ -79,7 +79,8 @@ * The sequence is assumed sorted by `t` ascending. */ function findNoteIndex(sequence, tick) { - let lo = 0, hi = sequence.length; + let lo = 0, + hi = sequence.length; while (lo < hi) { const mid = (lo + hi) >>> 1; if (sequence[mid].t <= tick) lo = mid + 1; diff --git a/public/js/core/BaseModal.js b/public/js/core/BaseModal.js index d1d431521..ae63518d2 100644 --- a/public/js/core/BaseModal.js +++ b/public/js/core/BaseModal.js @@ -23,203 +23,207 @@ * } */ class BaseModal { - /** - * @param {Object} options - * @param {string} options.id - Unique modal ID - * @param {string} [options.size='md'] - Modal size: 'sm', 'md', 'lg', 'xl', 'full' - * @param {string} [options.title=''] - Modal title (i18n key or plain text) - * @param {boolean} [options.closeOnEscape=true] - Close on ESC key - * @param {boolean} [options.closeOnOverlay=true] - Close on overlay click - * @param {boolean} [options.showCloseButton=true] - Show X close button - * @param {string} [options.customClass=''] - Additional CSS class for the dialog - */ - constructor(options = {}) { - this.options = { - id: options.id || 'modal-' + Date.now(), - size: options.size || 'md', - title: options.title || '', - closeOnEscape: options.closeOnEscape !== false, - closeOnOverlay: options.closeOnOverlay !== false, - showCloseButton: options.showCloseButton !== false, - customClass: options.customClass || '' - }; - - this.container = null; - this.dialog = null; - this.isOpen = false; - - // Internal handlers for cleanup - this._escHandler = null; - this._overlayHandler = null; - this._localeUnsubscribe = null; - this._focusTrapHandler = null; - this._previousFocus = null; + /** + * @param {Object} options + * @param {string} options.id - Unique modal ID + * @param {string} [options.size='md'] - Modal size: 'sm', 'md', 'lg', 'xl', 'full' + * @param {string} [options.title=''] - Modal title (i18n key or plain text) + * @param {boolean} [options.closeOnEscape=true] - Close on ESC key + * @param {boolean} [options.closeOnOverlay=true] - Close on overlay click + * @param {boolean} [options.showCloseButton=true] - Show X close button + * @param {string} [options.customClass=''] - Additional CSS class for the dialog + */ + constructor(options = {}) { + this.options = { + id: options.id || 'modal-' + Date.now(), + size: options.size || 'md', + title: options.title || '', + closeOnEscape: options.closeOnEscape !== false, + closeOnOverlay: options.closeOnOverlay !== false, + showCloseButton: options.showCloseButton !== false, + customClass: options.customClass || '' + }; + + this.container = null; + this.dialog = null; + this.isOpen = false; + + // Internal handlers for cleanup + this._escHandler = null; + this._overlayHandler = null; + this._localeUnsubscribe = null; + this._focusTrapHandler = null; + this._previousFocus = null; + } + + // ============================================ + // I18N SUPPORT + // ============================================ + + /** + * Translate a key using the global i18n system + * @param {string} key - Translation key + * @param {Object} [params] - Interpolation parameters + * @returns {string} Translated text or key as fallback + */ + t(key, params = {}) { + if (typeof i18n !== 'undefined' && i18n.t) { + return i18n.t(key, params); } - - // ============================================ - // I18N SUPPORT - // ============================================ - - /** - * Translate a key using the global i18n system - * @param {string} key - Translation key - * @param {Object} [params] - Interpolation parameters - * @returns {string} Translated text or key as fallback - */ - t(key, params = {}) { - if (typeof i18n !== 'undefined' && i18n.t) { - return i18n.t(key, params); - } - return key; + return key; + } + + /** + * Escape HTML to prevent XSS + * @param {string} text - Raw text + * @returns {string} Escaped HTML + */ + escape(text) { + if (typeof escapeHtml === 'function') { + return escapeHtml(text); } + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } - /** - * Escape HTML to prevent XSS - * @param {string} text - Raw text - * @returns {string} Escaped HTML - */ - escape(text) { - if (typeof escapeHtml === 'function') { - return escapeHtml(text); - } - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } + // ============================================ + // LIFECYCLE + // ============================================ - // ============================================ - // LIFECYCLE - // ============================================ + /** + * Open the modal + */ + open() { + if (this.isOpen) return; - /** - * Open the modal - */ - open() { - if (this.isOpen) return; + // Save current focus for restoration + this._previousFocus = document.activeElement; - // Save current focus for restoration - this._previousFocus = document.activeElement; + // Create DOM + this._createDOM(); - // Create DOM - this._createDOM(); + // Attach core handlers + this._attachCoreHandlers(); - // Attach core handlers - this._attachCoreHandlers(); + // Subscribe to locale changes + this._subscribeLocale(); - // Subscribe to locale changes - this._subscribeLocale(); + // Mark as open + this.isOpen = true; - // Mark as open - this.isOpen = true; + // Prevent body scroll + document.body.style.overflow = 'hidden'; - // Prevent body scroll - document.body.style.overflow = 'hidden'; + // Focus trap: focus first focusable element + requestAnimationFrame(() => { + this._focusFirst(); + }); - // Focus trap: focus first focusable element - requestAnimationFrame(() => { - this._focusFirst(); - }); + // Subclass hook + this.onOpen(); + } - // Subclass hook - this.onOpen(); - } + /** + * Close the modal + */ + close() { + if (!this.isOpen) return; - /** - * Close the modal - */ - close() { - if (!this.isOpen) return; + // Subclass hook + this.onClose(); - // Subclass hook - this.onClose(); + // Remove handlers + this._detachCoreHandlers(); - // Remove handlers - this._detachCoreHandlers(); + // Unsubscribe locale + this._unsubscribeLocale(); - // Unsubscribe locale - this._unsubscribeLocale(); - - // Remove DOM - if (this.container) { - this.container.remove(); - this.container = null; - this.dialog = null; - } + // Remove DOM + if (this.container) { + this.container.remove(); + this.container = null; + this.dialog = null; + } - // Restore body scroll - document.body.style.overflow = ''; + // Restore body scroll + document.body.style.overflow = ''; - // Restore focus - if (this._previousFocus && this._previousFocus.focus) { - this._previousFocus.focus(); - } - - this.isOpen = false; + // Restore focus + if (this._previousFocus && this._previousFocus.focus) { + this._previousFocus.focus(); } - /** - * Update modal content (e.g., after locale change) - */ - update() { - if (!this.isOpen || !this.dialog) return; + this.isOpen = false; + } - const headerEl = this.dialog.querySelector('.modal-header h2'); - if (headerEl) { - headerEl.textContent = this.t(this.options.title); - } + /** + * Update modal content (e.g., after locale change) + */ + update() { + if (!this.isOpen || !this.dialog) return; - const bodyEl = this.dialog.querySelector('.modal-body'); - if (bodyEl) { - bodyEl.innerHTML = this.renderBody(); - } + const headerEl = this.dialog.querySelector('.modal-header h2'); + if (headerEl) { + headerEl.textContent = this.t(this.options.title); + } - const footerEl = this.dialog.querySelector('.modal-footer'); - if (footerEl) { - footerEl.innerHTML = this.renderFooter(); - } + const bodyEl = this.dialog.querySelector('.modal-body'); + if (bodyEl) { + bodyEl.innerHTML = this.renderBody(); + } - // Subclass hook - this.onUpdate(); + const footerEl = this.dialog.querySelector('.modal-footer'); + if (footerEl) { + footerEl.innerHTML = this.renderFooter(); } - // ============================================ - // SUBCLASS HOOKS (override these) - // ============================================ + // Subclass hook + this.onUpdate(); + } - /** Override to provide modal body HTML */ - renderBody() { return ''; } + // ============================================ + // SUBCLASS HOOKS (override these) + // ============================================ - /** Override to provide modal footer HTML */ - renderFooter() { return ''; } + /** Override to provide modal body HTML */ + renderBody() { + return ''; + } - /** Called after modal is opened and DOM is ready */ - onOpen() {} + /** Override to provide modal footer HTML */ + renderFooter() { + return ''; + } - /** Called before modal DOM is removed */ - onClose() {} + /** Called after modal is opened and DOM is ready */ + onOpen() {} - /** Called after content is updated (e.g., locale change) */ - onUpdate() {} + /** Called before modal DOM is removed */ + onClose() {} - // ============================================ - // DOM CREATION - // ============================================ + /** Called after content is updated (e.g., locale change) */ + onUpdate() {} - _createDOM() { - // Overlay - this.container = document.createElement('div'); - this.container.className = 'modal-overlay'; - this.container.id = this.options.id + '-overlay'; - this.container.setAttribute('role', 'dialog'); - this.container.setAttribute('aria-modal', 'true'); - this.container.setAttribute('aria-labelledby', this.options.id + '-title'); + // ============================================ + // DOM CREATION + // ============================================ - // Dialog - const sizeClass = this.options.size !== 'md' ? `modal-${this.options.size}` : ''; - const customClass = this.options.customClass; - const dialogClass = ['modal-dialog', sizeClass, customClass].filter(Boolean).join(' '); + _createDOM() { + // Overlay + this.container = document.createElement('div'); + this.container.className = 'modal-overlay'; + this.container.id = this.options.id + '-overlay'; + this.container.setAttribute('role', 'dialog'); + this.container.setAttribute('aria-modal', 'true'); + this.container.setAttribute('aria-labelledby', this.options.id + '-title'); - this.container.innerHTML = ` + // Dialog + const sizeClass = this.options.size !== 'md' ? `modal-${this.options.size}` : ''; + const customClass = this.options.customClass; + const dialogClass = ['modal-dialog', sizeClass, customClass].filter(Boolean).join(' '); + + this.container.innerHTML = `
${this._renderHeader()} `; - this.dialog = this.container.querySelector('.modal-dialog'); + this.dialog = this.container.querySelector('.modal-dialog'); - document.body.appendChild(this.container); - } + document.body.appendChild(this.container); + } - _renderHeader() { - const closeBtn = this.options.showCloseButton - ? `` - : ''; + _renderHeader() { + const closeBtn = this.options.showCloseButton + ? `` + : ''; - return ` + return ` `; - } - - // ============================================ - // EVENT HANDLERS - // ============================================ - - _attachCoreHandlers() { - // ESC key - if (this.options.closeOnEscape) { - this._escHandler = (e) => { - if (e.key === 'Escape' && this.isOpen) { - e.preventDefault(); - this.close(); - } - }; - document.addEventListener('keydown', this._escHandler); + } + + // ============================================ + // EVENT HANDLERS + // ============================================ + + _attachCoreHandlers() { + // ESC key + if (this.options.closeOnEscape) { + this._escHandler = (e) => { + if (e.key === 'Escape' && this.isOpen) { + e.preventDefault(); + this.close(); } - - // Overlay click - if (this.options.closeOnOverlay) { - this._overlayHandler = (e) => { - if (e.target === this.container) { - this.close(); - } - }; - this.container.addEventListener('click', this._overlayHandler); - } - - // Close button - const closeBtn = this.container.querySelector('[data-action="close"]'); - if (closeBtn) { - closeBtn.addEventListener('click', () => this.close()); - } - - // Focus trap - this._focusTrapHandler = (e) => { - if (e.key === 'Tab' && this.dialog) { - const focusable = this.dialog.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ); - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - - if (e.shiftKey) { - if (document.activeElement === first) { - e.preventDefault(); - last?.focus(); - } - } else { - if (document.activeElement === last) { - e.preventDefault(); - first?.focus(); - } - } - } - }; - document.addEventListener('keydown', this._focusTrapHandler); + }; + document.addEventListener('keydown', this._escHandler); } - _detachCoreHandlers() { - if (this._escHandler) { - document.removeEventListener('keydown', this._escHandler); - this._escHandler = null; - } - if (this._overlayHandler && this.container) { - this.container.removeEventListener('click', this._overlayHandler); - this._overlayHandler = null; - } - if (this._focusTrapHandler) { - document.removeEventListener('keydown', this._focusTrapHandler); - this._focusTrapHandler = null; + // Overlay click + if (this.options.closeOnOverlay) { + this._overlayHandler = (e) => { + if (e.target === this.container) { + this.close(); } + }; + this.container.addEventListener('click', this._overlayHandler); } - // ============================================ - // LOCALE MANAGEMENT - // ============================================ - - _subscribeLocale() { - if (typeof i18n !== 'undefined' && i18n.onLocaleChange) { - this._localeUnsubscribe = i18n.onLocaleChange(() => { - this.update(); - }); - } + // Close button + const closeBtn = this.container.querySelector('[data-action="close"]'); + if (closeBtn) { + closeBtn.addEventListener('click', () => this.close()); } - _unsubscribeLocale() { - if (this._localeUnsubscribe) { - this._localeUnsubscribe(); - this._localeUnsubscribe = null; + // Focus trap + this._focusTrapHandler = (e) => { + if (e.key === 'Tab' && this.dialog) { + const focusable = this.dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last?.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first?.focus(); + } } + } + }; + document.addEventListener('keydown', this._focusTrapHandler); + } + + _detachCoreHandlers() { + if (this._escHandler) { + document.removeEventListener('keydown', this._escHandler); + this._escHandler = null; + } + if (this._overlayHandler && this.container) { + this.container.removeEventListener('click', this._overlayHandler); + this._overlayHandler = null; } + if (this._focusTrapHandler) { + document.removeEventListener('keydown', this._focusTrapHandler); + this._focusTrapHandler = null; + } + } - // ============================================ - // FOCUS MANAGEMENT - // ============================================ + // ============================================ + // LOCALE MANAGEMENT + // ============================================ - _focusFirst() { - if (!this.dialog) return; - const focusable = this.dialog.querySelector( - 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' - ); - if (focusable) { - focusable.focus(); - } + _subscribeLocale() { + if (typeof i18n !== 'undefined' && i18n.onLocaleChange) { + this._localeUnsubscribe = i18n.onLocaleChange(() => { + this.update(); + }); } + } - // ============================================ - // UTILITY - // ============================================ - - /** - * Query a DOM element within the modal - * @param {string} selector - CSS selector - * @returns {HTMLElement|null} - */ - $(selector) { - return this.dialog ? this.dialog.querySelector(selector) : null; + _unsubscribeLocale() { + if (this._localeUnsubscribe) { + this._localeUnsubscribe(); + this._localeUnsubscribe = null; } - - /** - * Query all matching DOM elements within the modal - * @param {string} selector - CSS selector - * @returns {NodeList} - */ - $$(selector) { - return this.dialog ? this.dialog.querySelectorAll(selector) : []; + } + + // ============================================ + // FOCUS MANAGEMENT + // ============================================ + + _focusFirst() { + if (!this.dialog) return; + const focusable = this.dialog.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (focusable) { + focusable.focus(); } + } + + // ============================================ + // UTILITY + // ============================================ + + /** + * Query a DOM element within the modal + * @param {string} selector - CSS selector + * @returns {HTMLElement|null} + */ + $(selector) { + return this.dialog ? this.dialog.querySelector(selector) : null; + } + + /** + * Query all matching DOM elements within the modal + * @param {string} selector - CSS selector + * @returns {NodeList} + */ + $$(selector) { + return this.dialog ? this.dialog.querySelectorAll(selector) : []; + } } // Expose globally if (typeof window !== 'undefined') { - window.BaseModal = BaseModal; + window.BaseModal = BaseModal; } diff --git a/public/js/core/EventBus.js b/public/js/core/EventBus.js index edaf27907..03f47cbaa 100644 --- a/public/js/core/EventBus.js +++ b/public/js/core/EventBus.js @@ -107,7 +107,17 @@ class EventBus { const index = listeners.findIndex((l) => l.callback === callback); if (index !== -1) { + const removed = listeners[index]; listeners.splice(index, 1); + // Cancel any pending debounce timer for the removed listener; otherwise a + // queued debounced callback still fires after the caller unsubscribed + // (e.g. a view torn down within the debounce window), touching state it + // already released. + const key = `${event}_${removed.id}`; + if (this.debounceTimers.has(key)) { + clearTimeout(this.debounceTimers.get(key)); + this.debounceTimers.delete(key); + } } if (listeners.length === 0) { @@ -218,6 +228,12 @@ class EventBus { const timer = setTimeout(() => { this.executeCallback(listener, data); this.debounceTimers.delete(key); + // A `once` listener that also debounces must still be removed after + // it fires; the `continue` below skips the normal once-removal path, + // so handle it here or it would fire (debounced) on every emit. + if (listener.once) { + this.off(event, listener.callback); + } }, listener.debounce); this.debounceTimers.set(key, timer); diff --git a/public/js/features/BaseLaneEditor.js b/public/js/features/BaseLaneEditor.js index 3826566cf..5635f43b7 100644 --- a/public/js/features/BaseLaneEditor.js +++ b/public/js/features/BaseLaneEditor.js @@ -38,168 +38,193 @@ */ (function () { - 'use strict'; - + 'use strict'; + + /** + * Debounce window between `saveState()` calls (ms). Matches the + * historical 100 ms value used by all three editors. + */ + const DEFAULT_SAVE_DEBOUNCE_MS = 100; + + /** History depth cap (audit §8.3). */ + const DEFAULT_HISTORY_CAP = 50; + + /** + * Default left margin reserved for value-axis labels in the grid + * buffer canvas. Subclasses can override via `_labelMargin()`. + * Matches the piano roll's left chrome (CanvasPianoRollRenderer + * KB_WIDTH = SB_W 24 + KB_W 40 = 64) so the lane editor's columns + * line up vertically with the notes above. + */ + const DEFAULT_LABEL_MARGIN = 64; + + class BaseLaneEditor { /** - * Debounce window between `saveState()` calls (ms). Matches the - * historical 100 ms value used by all three editors. + * @param {HTMLElement} container + * @param {Object} options + * @param {number} [options.height] + * @param {number} [options.timebase=480] + * @param {number} [options.xrange=1920] + * @param {number} [options.xoffset=0] + * @param {number} [options.grid=15] + * @param {Function} [options.onChange] */ - const DEFAULT_SAVE_DEBOUNCE_MS = 100; - - /** History depth cap (audit §8.3). */ - const DEFAULT_HISTORY_CAP = 50; - - /** - * Default left margin reserved for value-axis labels in the grid - * buffer canvas. Subclasses can override via `_labelMargin()`. - * Matches the piano roll's left chrome (CanvasPianoRollRenderer - * KB_WIDTH = SB_W 24 + KB_W 40 = 64) so the lane editor's columns - * line up vertically with the notes above. - */ - const DEFAULT_LABEL_MARGIN = 64; - - class BaseLaneEditor { - /** - * @param {HTMLElement} container - * @param {Object} options - * @param {number} [options.height] - * @param {number} [options.timebase=480] - * @param {number} [options.xrange=1920] - * @param {number} [options.xoffset=0] - * @param {number} [options.grid=15] - * @param {Function} [options.onChange] - */ - constructor(container, options = {}) { - this.container = container; - - const defaultHeight = (typeof MidiEditorConstants !== 'undefined') - ? MidiEditorConstants.defaultEditorHeight - : 150; - - this.options = { - height: options.height || defaultHeight, - timebase: options.timebase || 480, - xrange: options.xrange || 1920, - xoffset: options.xoffset || 0, - grid: options.grid || 15, - onChange: options.onChange || null, - ...options - }; - - // Tool state (shared state machine — values defined by subclass usage). - this.currentTool = 'select'; - this.curveType = 'linear'; - this.isDrawing = false; - this.lastDrawPosition = null; - this.lastDrawTicks = null; - this.lineStart = null; - this.dragStart = null; - this.selectionRect = null; - - // Selection. Subclasses pick what `_idOf()` returns (sequence index - // for note-based editors, item.id for event-based editors). - this.selectedIds = new Set(); - - // Channel filtering (Velocity / CC). Tempo ignores it. - this.currentChannel = 0; - this.activeChannels = new Set([0]); - - // Undo/redo stack of cloned-array snapshots of - // `this[this._dataField()]` (see _cloneArr). - this.history = []; - this.historyIndex = -1; - this.historyCap = DEFAULT_HISTORY_CAP; - - // RAF coalescing for render() and mousemove() (audit §6.3). - this._renderRafId = 0; - this._mouseMoveRAF = null; - this._saveStateTimer = null; - - // Off-screen grid cache. - this.gridCanvas = null; - this.gridCtx = null; - this.gridDirty = true; - } + constructor(container, options = {}) { + this.container = container; + + const defaultHeight = + typeof MidiEditorConstants !== 'undefined' ? MidiEditorConstants.defaultEditorHeight : 150; + + this.options = { + height: options.height || defaultHeight, + timebase: options.timebase || 480, + xrange: options.xrange || 1920, + xoffset: options.xoffset || 0, + grid: options.grid || 15, + onChange: options.onChange || null, + ...options + }; + + // Tool state (shared state machine — values defined by subclass usage). + this.currentTool = 'select'; + this.curveType = 'linear'; + this.isDrawing = false; + this.lastDrawPosition = null; + this.lastDrawTicks = null; + this.lineStart = null; + this.dragStart = null; + this.selectionRect = null; + + // Selection. Subclasses pick what `_idOf()` returns (sequence index + // for note-based editors, item.id for event-based editors). + this.selectedIds = new Set(); + + // Channel filtering (Velocity / CC). Tempo ignores it. + this.currentChannel = 0; + this.activeChannels = new Set([0]); + + // Undo/redo stack of cloned-array snapshots of + // `this[this._dataField()]` (see _cloneArr). + this.history = []; + this.historyIndex = -1; + this.historyCap = DEFAULT_HISTORY_CAP; + + // RAF coalescing for render() and mousemove() (audit §6.3). + this._renderRafId = 0; + this._mouseMoveRAF = null; + this._saveStateTimer = null; + + // Off-screen grid cache. + this.gridCanvas = null; + this.gridCtx = null; + this.gridDirty = true; + } - // ================================================================= - // OVERRIDABLE HOOKS — subclasses customize behaviour here. - // Defaults are deliberately conservative so that an editor can - // start subclassing without implementing every hook on day one. - // ================================================================= + // ================================================================= + // OVERRIDABLE HOOKS — subclasses customize behaviour here. + // Defaults are deliberately conservative so that an editor can + // start subclassing without implementing every hook on day one. + // ================================================================= - /** @returns {string} CSS class for the editor's root element. */ - _className() { return 'lane-editor'; } + /** @returns {string} CSS class for the editor's root element. */ + _className() { + return 'lane-editor'; + } - /** @returns {string} Property name for the data array. */ - _dataField() { return 'events'; } + /** @returns {string} Property name for the data array. */ + _dataField() { + return 'events'; + } - /** @returns {Array} Current data array (read-through). */ - _getDataArray() { return this[this._dataField()] || []; } + /** @returns {Array} Current data array (read-through). */ + _getDataArray() { + return this[this._dataField()] || []; + } - /** Replace the data array in-place. */ - _setDataArray(arr) { this[this._dataField()] = arr || []; } + /** Replace the data array in-place. */ + _setDataArray(arr) { + this[this._dataField()] = arr || []; + } - /** Items visible after filtering (channels, CC type, …). */ - _getVisibleData() { return this._getDataArray(); } + /** Items visible after filtering (channels, CC type, …). */ + _getVisibleData() { + return this._getDataArray(); + } - /** - * @param {*} item - * @returns {*} Identity for selection. By default, the item's `id`. - */ - _idOf(item) { return item ? item.id : undefined; } + /** + * @param {*} item + * @returns {*} Identity for selection. By default, the item's `id`. + */ + _idOf(item) { + return item ? item.id : undefined; + } - /** Convert a value-axis number to a pixel Y. Subclass MUST override. */ - _valueToY(/* value */) { - throw new Error('BaseLaneEditor: _valueToY() must be overridden'); - } + /** Convert a value-axis number to a pixel Y. Subclass MUST override. */ + _valueToY(/* value */) { + throw new Error('BaseLaneEditor: _valueToY() must be overridden'); + } - /** Inverse of `_valueToY`. Subclass MUST override. */ - _yToValue(/* y */) { - throw new Error('BaseLaneEditor: _yToValue() must be overridden'); - } + /** Inverse of `_valueToY`. Subclass MUST override. */ + _yToValue(/* y */) { + throw new Error('BaseLaneEditor: _yToValue() must be overridden'); + } - /** Pixel margin reserved for value labels on the left edge. */ - _labelMargin() { return DEFAULT_LABEL_MARGIN; } + /** Pixel margin reserved for value labels on the left edge. */ + _labelMargin() { + return DEFAULT_LABEL_MARGIN; + } - /** Subclasses paint their values (bars/curves/lines). */ - _renderData() { /* override */ } + /** Subclasses paint their values (bars/curves/lines). */ + _renderData() { + /* override */ + } - /** Optional decoration (Tempo: 120 BPM line; CC: zero line). */ - _renderCenterLine() { /* optional override */ } + /** Optional decoration (Tempo: 120 BPM line; CC: zero line). */ + _renderCenterLine() { + /* optional override */ + } - /** Optional value-axis grid lines + labels. */ - _renderHorizontalGrid(/* ctx, isDark */) { /* optional override */ } + /** Optional value-axis grid lines + labels. */ + _renderHorizontalGrid(/* ctx, isDark */) { + /* optional override */ + } - /** Add an extra listener (e.g. Tempo's wheel handler). */ - _attachExtraListeners() { /* optional override */ } + /** Add an extra listener (e.g. Tempo's wheel handler). */ + _attachExtraListeners() { + /* optional override */ + } - /** Mirror `_attachExtraListeners` — invoked from `destroy()`. */ - _detachExtraListeners() { /* optional override */ } + /** Mirror `_attachExtraListeners` — invoked from `destroy()`. */ + _detachExtraListeners() { + /* optional override */ + } - /** Called after an undo/redo restores the data array. */ - _onHistoryRestore() { /* optional override */ } + /** Called after an undo/redo restores the data array. */ + _onHistoryRestore() { + /* optional override */ + } - // ================================================================= - // CONSTRUCTION + LIFECYCLE — identical across all editors. - // ================================================================= + // ================================================================= + // CONSTRUCTION + LIFECYCLE — identical across all editors. + // ================================================================= - /** - * Standard init pipeline. Subclasses normally don't override. - * The constructor doesn't call this automatically so the subclass - * has a chance to initialise specific fields before the UI/listeners - * touch them. - */ - init() { - this.createUI(); - this.setupEventListeners(); - } + /** + * Standard init pipeline. Subclasses normally don't override. + * The constructor doesn't call this automatically so the subclass + * has a chance to initialise specific fields before the UI/listeners + * touch them. + */ + init() { + this.createUI(); + this.setupEventListeners(); + } - createUI() { - const isDark = document.body.classList.contains('dark-mode'); + createUI() { + const isDark = document.body.classList.contains('dark-mode'); - this.element = document.createElement('div'); - this.element.className = this._className(); - this.element.style.cssText = ` + this.element = document.createElement('div'); + this.element.className = this._className(); + this.element.style.cssText = ` width: 100%; flex: 1; display: flex; @@ -211,8 +236,8 @@ min-height: 0; `; - this.canvas = document.createElement('canvas'); - this.canvas.style.cssText = ` + this.canvas = document.createElement('canvas'); + this.canvas.style.cssText = ` position: absolute; top: 0; left: 0; @@ -220,381 +245,424 @@ height: 100%; cursor: crosshair; `; - this.ctx = this.canvas.getContext('2d'); + this.ctx = this.canvas.getContext('2d'); - this.element.appendChild(this.canvas); - this.container.appendChild(this.element); + this.element.appendChild(this.canvas); + this.container.appendChild(this.element); - this.resize(); - } + this.resize(); + } - setupEventListeners() { - this._boundMouseDown = (e) => this.handleMouseDown(e); - this._boundMouseMove = (e) => { - if (this._mouseMoveRAF) return; - this._mouseMoveRAF = requestAnimationFrame(() => { - this._mouseMoveRAF = null; - this.handleMouseMove(e); - }); - }; - this._boundMouseUp = (e) => this.handleMouseUp(e); - this._boundMouseLeave = (e) => this.handleMouseLeave(e); - this._boundKeyDown = (e) => this.handleKeyDown(e); - this._boundResize = () => this.resize(); - this._boundThemeChanged = () => this._onThemeChanged(); - - this.canvas.addEventListener('mousedown', this._boundMouseDown); - this.canvas.addEventListener('mousemove', this._boundMouseMove); - this.canvas.addEventListener('mouseup', this._boundMouseUp); - this.canvas.addEventListener('mouseleave', this._boundMouseLeave); - - document.addEventListener('keydown', this._boundKeyDown); - window.addEventListener('resize', this._boundResize); - document.addEventListener('theme-changed', this._boundThemeChanged); - - this._attachExtraListeners(); - } + setupEventListeners() { + this._boundMouseDown = (e) => this.handleMouseDown(e); + this._boundMouseMove = (e) => { + if (this._mouseMoveRAF) return; + this._mouseMoveRAF = requestAnimationFrame(() => { + this._mouseMoveRAF = null; + this.handleMouseMove(e); + }); + }; + this._boundMouseUp = (e) => this.handleMouseUp(e); + this._boundMouseLeave = (e) => this.handleMouseLeave(e); + this._boundKeyDown = (e) => this.handleKeyDown(e); + this._boundResize = () => this.resize(); + this._boundThemeChanged = () => this._onThemeChanged(); + + this.canvas.addEventListener('mousedown', this._boundMouseDown); + this.canvas.addEventListener('mousemove', this._boundMouseMove); + this.canvas.addEventListener('mouseup', this._boundMouseUp); + this.canvas.addEventListener('mouseleave', this._boundMouseLeave); + + document.addEventListener('keydown', this._boundKeyDown); + window.addEventListener('resize', this._boundResize); + document.addEventListener('theme-changed', this._boundThemeChanged); + + this._attachExtraListeners(); + } - _onThemeChanged() { - const isDark = document.body.classList.contains('dark-mode'); - if (this.element) { - this.element.style.background = isDark ? '#1a1a1a' : '#f0f4ff'; - this.element.style.borderTopColor = isDark ? '#333' : '#d4daff'; - } - this.gridDirty = true; - this.renderThrottled(); - } + _onThemeChanged() { + const isDark = document.body.classList.contains('dark-mode'); + if (this.element) { + this.element.style.background = isDark ? '#1a1a1a' : '#f0f4ff'; + this.element.style.borderTopColor = isDark ? '#333' : '#d4daff'; + } + this.gridDirty = true; + this.renderThrottled(); + } - resize() { - // Force reflow up the cascade so we capture the final size. - if (this.container) void this.container.offsetHeight; - if (this.container?.parentElement) void this.container.parentElement.offsetHeight; - if (this.element) void this.element.offsetHeight; - - const rect = this.element.getBoundingClientRect(); - const width = rect.width; - const height = rect.height; - if (width <= 0 || height <= 0) return; - - this.canvas.width = width; - this.canvas.height = height; - - if (!this.gridCanvas) { - this.gridCanvas = document.createElement('canvas'); - this.gridCanvas.width = width; - this.gridCanvas.height = height; - this.gridCtx = this.gridCanvas.getContext('2d'); - } else if (this.gridCanvas.width !== width || this.gridCanvas.height !== height) { - this.gridCanvas.width = width; - this.gridCanvas.height = height; - } - this.gridDirty = true; - this.renderThrottled(); - } + resize() { + // Force reflow up the cascade so we capture the final size. + if (this.container) void this.container.offsetHeight; + if (this.container?.parentElement) void this.container.parentElement.offsetHeight; + if (this.element) void this.element.offsetHeight; + + const rect = this.element.getBoundingClientRect(); + const width = rect.width; + const height = rect.height; + if (width <= 0 || height <= 0) return; + + this.canvas.width = width; + this.canvas.height = height; + + if (!this.gridCanvas) { + this.gridCanvas = document.createElement('canvas'); + this.gridCanvas.width = width; + this.gridCanvas.height = height; + this.gridCtx = this.gridCanvas.getContext('2d'); + } else if (this.gridCanvas.width !== width || this.gridCanvas.height !== height) { + this.gridCanvas.width = width; + this.gridCanvas.height = height; + } + this.gridDirty = true; + this.renderThrottled(); + } - destroy() { - if (this._mouseMoveRAF) cancelAnimationFrame(this._mouseMoveRAF); - if (this._renderRafId) { cancelAnimationFrame(this._renderRafId); this._renderRafId = 0; } - if (this._saveStateTimer) clearTimeout(this._saveStateTimer); - - if (this.canvas) { - this.canvas.removeEventListener('mousedown', this._boundMouseDown); - this.canvas.removeEventListener('mousemove', this._boundMouseMove); - this.canvas.removeEventListener('mouseup', this._boundMouseUp); - this.canvas.removeEventListener('mouseleave', this._boundMouseLeave); - } - document.removeEventListener('keydown', this._boundKeyDown); - window.removeEventListener('resize', this._boundResize); - document.removeEventListener('theme-changed', this._boundThemeChanged); - - this._detachExtraListeners(); - - if (this.element?.parentNode) { - this.element.parentNode.removeChild(this.element); - } - } + destroy() { + if (this._mouseMoveRAF) cancelAnimationFrame(this._mouseMoveRAF); + if (this._renderRafId) { + cancelAnimationFrame(this._renderRafId); + this._renderRafId = 0; + } + if (this._saveStateTimer) clearTimeout(this._saveStateTimer); + + if (this.canvas) { + this.canvas.removeEventListener('mousedown', this._boundMouseDown); + this.canvas.removeEventListener('mousemove', this._boundMouseMove); + this.canvas.removeEventListener('mouseup', this._boundMouseUp); + this.canvas.removeEventListener('mouseleave', this._boundMouseLeave); + } + document.removeEventListener('keydown', this._boundKeyDown); + window.removeEventListener('resize', this._boundResize); + document.removeEventListener('theme-changed', this._boundThemeChanged); + + this._detachExtraListeners(); + + if (this.element?.parentNode) { + this.element.parentNode.removeChild(this.element); + } + } - // ================================================================= - // COORDINATE CONVERSION - // ================================================================= + // ================================================================= + // COORDINATE CONVERSION + // ================================================================= - ticksToX(ticks) { - const m = this._labelMargin(); - const usable = Math.max(1, this.canvas.width - m); - return m + ((ticks - this.options.xoffset) / this.options.xrange) * usable; - } + ticksToX(ticks) { + const m = this._labelMargin(); + const usable = Math.max(1, this.canvas.width - m); + return m + ((ticks - this.options.xoffset) / this.options.xrange) * usable; + } - xToTicks(x) { - const m = this._labelMargin(); - const usable = Math.max(1, this.canvas.width - m); - return Math.round(((x - m) / usable) * this.options.xrange + this.options.xoffset); - } + xToTicks(x) { + const m = this._labelMargin(); + const usable = Math.max(1, this.canvas.width - m); + return Math.round(((x - m) / usable) * this.options.xrange + this.options.xoffset); + } - snapToGrid(ticks) { - const g = this.options.grid; - return Math.round(ticks / g) * g; - } + snapToGrid(ticks) { + const g = this.options.grid; + return Math.round(ticks / g) * g; + } - // ================================================================= - // VIEWPORT SETTERS — used by the modal's sync layer. - // ================================================================= + // ================================================================= + // VIEWPORT SETTERS — used by the modal's sync layer. + // ================================================================= - setXRange(xrange) { this.options.xrange = xrange; this.gridDirty = true; this.renderThrottled(); } - setXOffset(xoffset){ this.options.xoffset = xoffset; this.gridDirty = true; this.renderThrottled(); } - setGrid(grid) { this.options.grid = grid; this.gridDirty = true; this.renderThrottled(); } + setXRange(xrange) { + this.options.xrange = xrange; + this.gridDirty = true; + this.renderThrottled(); + } + setXOffset(xoffset) { + this.options.xoffset = xoffset; + this.gridDirty = true; + this.renderThrottled(); + } + setGrid(grid) { + this.options.grid = grid; + this.gridDirty = true; + this.renderThrottled(); + } - // ================================================================= - // TOOL STATE - // ================================================================= + // ================================================================= + // TOOL STATE + // ================================================================= - setTool(tool) { - this.currentTool = tool; - if (this.canvas) this.canvas.style.cursor = tool === 'draw' ? 'crosshair' : 'default'; - } + setTool(tool) { + this.currentTool = tool; + if (this.canvas) this.canvas.style.cursor = tool === 'draw' ? 'crosshair' : 'default'; + } - setCurveType(curveType) { - this.curveType = curveType; - } + setCurveType(curveType) { + this.curveType = curveType; + } - /** - * Interpolation curves used by `createLine()` in subclasses. - * @param {number} t - Linear progress 0..1. - * @returns {number} Eased progress. - */ - applyCurve(t) { - switch (this.curveType) { - case 'linear': return t; - case 'exponential': return t * t; - case 'logarithmic': return Math.sqrt(t); - case 'sine': return (1 - Math.cos(t * Math.PI)) / 2; - default: return t; - } - } + /** + * Interpolation curves used by `createLine()` in subclasses. + * @param {number} t - Linear progress 0..1. + * @returns {number} Eased progress. + */ + applyCurve(t) { + switch (this.curveType) { + case 'linear': + return t; + case 'exponential': + return t * t; + case 'logarithmic': + return Math.sqrt(t); + case 'sine': + return (1 - Math.cos(t * Math.PI)) / 2; + default: + return t; + } + } - cancelInteractions() { - this.lineStart = null; - this.selectionStart = null; - this.selectionRect = null; - this.dragStart = null; - this.isDrawing = false; - this.lastDrawPosition = null; - this.lastDrawTicks = null; - } + cancelInteractions() { + this.lineStart = null; + this.selectionStart = null; + this.selectionRect = null; + this.dragStart = null; + this.isDrawing = false; + this.lastDrawPosition = null; + this.lastDrawTicks = null; + } - // ================================================================= - // SELECTION - // ================================================================= + // ================================================================= + // SELECTION + // ================================================================= - selectAll() { - this.selectedIds.clear(); - for (const item of this._getDataArray()) { - this.selectedIds.add(this._idOf(item)); - } - this.renderThrottled(); - } + selectAll() { + this.selectedIds.clear(); + for (const item of this._getDataArray()) { + this.selectedIds.add(this._idOf(item)); + } + this.renderThrottled(); + } - clearSelection() { - if (this.selectedIds.size > 0) { - this.selectedIds.clear(); - this.renderThrottled(); - } - } + clearSelection() { + if (this.selectedIds.size > 0) { + this.selectedIds.clear(); + this.renderThrottled(); + } + } - /** - * Rectangle selection in pixel space. Subclasses override - * `_getVisibleData()` if filtering is needed. - */ - selectInRect(x1, y1, x2, y2) { - const left = Math.min(x1, x2); - const right = Math.max(x1, x2); - const top = Math.min(y1, y2); - const bottom = Math.max(y1, y2); - - for (const item of this._getVisibleData()) { - const ex = this.ticksToX(this._ticksOf(item)); - const ey = this._valueToY(this._valueOf(item)); - if (ex >= left && ex <= right && ey >= top && ey <= bottom) { - this.selectedIds.add(this._idOf(item)); - } - } + /** + * Rectangle selection in pixel space. Subclasses override + * `_getVisibleData()` if filtering is needed. + */ + selectInRect(x1, y1, x2, y2) { + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + const top = Math.min(y1, y2); + const bottom = Math.max(y1, y2); + + for (const item of this._getVisibleData()) { + const ex = this.ticksToX(this._ticksOf(item)); + const ey = this._valueToY(this._valueOf(item)); + if (ex >= left && ex <= right && ey >= top && ey <= bottom) { + this.selectedIds.add(this._idOf(item)); } + } + } - /** Tick coordinate of an item. Defaults to `item.ticks`. */ - _ticksOf(item) { return item.ticks; } - - /** Value of an item (used by `selectInRect`). Override per editor. */ - _valueOf(item) { return item.value ?? 0; } + /** Tick coordinate of an item. Defaults to `item.ticks`. */ + _ticksOf(item) { + return item.ticks; + } - // ================================================================= - // UNDO / REDO — cloned-array snapshot of the data array. Items are - // flat objects, so a per-item shallow clone is a full snapshot and - // far cheaper than JSON.stringify/parse (no large string allocation - // / GC pause on each edit or undo). - // ================================================================= + /** Value of an item (used by `selectInRect`). Override per editor. */ + _valueOf(item) { + return item.value ?? 0; + } - _cloneArr(arr) { - const src = arr || []; - const out = new Array(src.length); - for (let i = 0; i < src.length; i++) out[i] = { ...src[i] }; - return out; - } + // ================================================================= + // UNDO / REDO — cloned-array snapshot of the data array. Items are + // flat objects, so a per-item shallow clone is a full snapshot and + // far cheaper than JSON.stringify/parse (no large string allocation + // / GC pause on each edit or undo). + // ================================================================= + + _cloneArr(arr) { + const src = arr || []; + const out = new Array(src.length); + for (let i = 0; i < src.length; i++) out[i] = { ...src[i] }; + return out; + } - /** Public snapshot helper so subclasses keep the history format consistent. */ - _snapshotData() { return this._cloneArr(this._getDataArray()); } + /** Public snapshot helper so subclasses keep the history format consistent. */ + _snapshotData() { + return this._cloneArr(this._getDataArray()); + } - saveState() { - if (this._saveStateTimer) clearTimeout(this._saveStateTimer); - this._saveStateTimer = setTimeout(() => this._doSaveState(), DEFAULT_SAVE_DEBOUNCE_MS); - } + saveState() { + if (this._saveStateTimer) clearTimeout(this._saveStateTimer); + this._saveStateTimer = setTimeout(() => this._doSaveState(), DEFAULT_SAVE_DEBOUNCE_MS); + } - _doSaveState() { - this._saveStateTimer = null; - const snapshot = this._snapshotData(); + _doSaveState() { + this._saveStateTimer = null; + const snapshot = this._snapshotData(); - if (this.historyIndex < this.history.length - 1) { - this.history = this.history.slice(0, this.historyIndex + 1); - } - this.history.push(snapshot); - this.historyIndex++; + if (this.historyIndex < this.history.length - 1) { + this.history = this.history.slice(0, this.historyIndex + 1); + } + this.history.push(snapshot); + this.historyIndex++; - if (this.history.length > this.historyCap) { - this.history.shift(); - this.historyIndex--; - } + if (this.history.length > this.historyCap) { + this.history.shift(); + this.historyIndex--; + } - this.notifyChange(); - } + this.notifyChange(); + } - undo() { - if (this._saveStateTimer) clearTimeout(this._saveStateTimer); - if (this.historyIndex <= 0) return false; - this.historyIndex--; - return this._restoreFromHistory(); - } + undo() { + if (this._saveStateTimer) clearTimeout(this._saveStateTimer); + if (this.historyIndex <= 0) return false; + this.historyIndex--; + return this._restoreFromHistory(); + } - redo() { - if (this._saveStateTimer) clearTimeout(this._saveStateTimer); - if (this.historyIndex >= this.history.length - 1) return false; - this.historyIndex++; - return this._restoreFromHistory(); - } + redo() { + if (this._saveStateTimer) clearTimeout(this._saveStateTimer); + if (this.historyIndex >= this.history.length - 1) return false; + this.historyIndex++; + return this._restoreFromHistory(); + } - _restoreFromHistory() { - this._setDataArray(this._cloneArr(this.history[this.historyIndex])); - this.selectedIds.clear(); - this._onHistoryRestore(); - this.renderThrottled(); - this.notifyChange(); - return true; - } + _restoreFromHistory() { + this._setDataArray(this._cloneArr(this.history[this.historyIndex])); + this.selectedIds.clear(); + this._onHistoryRestore(); + this.renderThrottled(); + this.notifyChange(); + return true; + } - notifyChange() { - if (typeof this.options.onChange === 'function') { - try { this.options.onChange(this._getDataArray()); } - catch (_) { /* best-effort */ } - } + notifyChange() { + if (typeof this.options.onChange === 'function') { + try { + this.options.onChange(this._getDataArray()); + } catch (_) { + /* best-effort */ } + } + } - // ================================================================= - // RENDER PIPELINE — base draws scaffolding, subclasses draw glyphs. - // ================================================================= + // ================================================================= + // RENDER PIPELINE — base draws scaffolding, subclasses draw glyphs. + // ================================================================= - renderThrottled() { - if (this._renderRafId) return; - this._renderRafId = requestAnimationFrame(() => { - this._renderRafId = 0; - this.render(); - }); - } + renderThrottled() { + if (this._renderRafId) return; + this._renderRafId = requestAnimationFrame(() => { + this._renderRafId = 0; + this.render(); + }); + } - render() { - if (!this.ctx || !this.canvas) return; - this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + render() { + if (!this.ctx || !this.canvas) return; + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - this._renderGrid(); - this._renderCenterLine(); - this._renderData(); - this._renderSelectionRect(); - } + this._renderGrid(); + this._renderCenterLine(); + this._renderData(); + this._renderSelectionRect(); + } - _renderGrid() { - if (this.gridDirty || !this.gridCanvas) { - this._renderGridToBuffer(); - this.gridDirty = false; - } - this.ctx.drawImage(this.gridCanvas, 0, 0); - } + _renderGrid() { + if (this.gridDirty || !this.gridCanvas) { + this._renderGridToBuffer(); + this.gridDirty = false; + } + this.ctx.drawImage(this.gridCanvas, 0, 0); + } - /** - * Time-axis grid + label margin. Value-axis is delegated to - * `_renderHorizontalGrid()`. - */ - _renderGridToBuffer() { - if (!this.gridCtx) return; - const ctx = this.gridCtx; - const labelMargin = this._labelMargin(); - const isDark = document.body.classList.contains('dark-mode'); - - ctx.clearRect(0, 0, this.gridCanvas.width, this.gridCanvas.height); - - // Label area background. - ctx.fillStyle = isDark ? '#1e1e1e' : '#e0e4f8'; - ctx.fillRect(0, 0, labelMargin, this.gridCanvas.height); - - // Vertical (time) grid — heavier on the beat. - const ticksPerBeat = this.options.timebase; - const gridSize = this.options.grid || ticksPerBeat; - const startTick = Math.floor(this.options.xoffset / ticksPerBeat) * ticksPerBeat; - const endTick = this.options.xoffset + this.options.xrange; - - for (let t = startTick; t <= endTick; t += gridSize) { - const x = this.ticksToX(t); - if (x < labelMargin || x > this.gridCanvas.width) continue; - const isBeat = (t % ticksPerBeat) === 0; - ctx.strokeStyle = isDark - ? (isBeat ? '#383838' : '#2a2a2a') - : (isBeat ? '#d4daff' : '#e8ecff'); - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, this.gridCanvas.height); - ctx.stroke(); - } - - // Value-axis grid (delegated to subclass). - this._renderHorizontalGrid(ctx, isDark); - } + /** + * Time-axis grid + label margin. Value-axis is delegated to + * `_renderHorizontalGrid()`. + */ + _renderGridToBuffer() { + if (!this.gridCtx) return; + const ctx = this.gridCtx; + const labelMargin = this._labelMargin(); + const isDark = document.body.classList.contains('dark-mode'); + + ctx.clearRect(0, 0, this.gridCanvas.width, this.gridCanvas.height); + + // Label area background. + ctx.fillStyle = isDark ? '#1e1e1e' : '#e0e4f8'; + ctx.fillRect(0, 0, labelMargin, this.gridCanvas.height); + + // Vertical (time) grid — heavier on the beat. + const ticksPerBeat = this.options.timebase; + const gridSize = this.options.grid || ticksPerBeat; + const startTick = Math.floor(this.options.xoffset / ticksPerBeat) * ticksPerBeat; + const endTick = this.options.xoffset + this.options.xrange; + + for (let t = startTick; t <= endTick; t += gridSize) { + const x = this.ticksToX(t); + if (x < labelMargin || x > this.gridCanvas.width) continue; + const isBeat = t % ticksPerBeat === 0; + ctx.strokeStyle = isDark + ? isBeat + ? '#383838' + : '#2a2a2a' + : isBeat + ? '#d4daff' + : '#e8ecff'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, this.gridCanvas.height); + ctx.stroke(); + } + + // Value-axis grid (delegated to subclass). + this._renderHorizontalGrid(ctx, isDark); + } - _renderSelectionRect() { - const r = this.selectionRect; - if (!r || r.currentX === undefined) return; - this.ctx.strokeStyle = '#2196F3'; - this.ctx.lineWidth = 1; - this.ctx.setLineDash([5, 5]); - this.ctx.strokeRect(r.x, r.y, r.currentX - r.x, r.currentY - r.y); - this.ctx.setLineDash([]); - } + _renderSelectionRect() { + const r = this.selectionRect; + if (!r || r.currentX === undefined) return; + this.ctx.strokeStyle = '#2196F3'; + this.ctx.lineWidth = 1; + this.ctx.setLineDash([5, 5]); + this.ctx.strokeRect(r.x, r.y, r.currentX - r.x, r.currentY - r.y); + this.ctx.setLineDash([]); + } - // ================================================================= - // INPUT — base intentionally does NOT implement handleMouseDown / - // Move / Up / Leave / KeyDown. The semantics diverge enough across - // editors (note vs event vs sorted-tempo points) that factoring - // them properly would require a unified data model first. Keep - // them in subclasses for now. - // ================================================================= + // ================================================================= + // INPUT — base intentionally does NOT implement handleMouseDown / + // Move / Up / Leave / KeyDown. The semantics diverge enough across + // editors (note vs event vs sorted-tempo points) that factoring + // them properly would require a unified data model first. Keep + // them in subclasses for now. + // ================================================================= - handleMouseDown(/* e */) { /* override */ } - handleMouseMove(/* e */) { /* override */ } - handleMouseUp(/* e */) { /* override */ } - handleMouseLeave(e) { this.handleMouseUp(e); } - handleKeyDown(/* e */) { /* override */ } + handleMouseDown(/* e */) { + /* override */ } - - // Dual export. - if (typeof window !== 'undefined') { - window.BaseLaneEditor = BaseLaneEditor; + handleMouseMove(/* e */) { + /* override */ + } + handleMouseUp(/* e */) { + /* override */ + } + handleMouseLeave(e) { + this.handleMouseUp(e); } - if (typeof module !== 'undefined' && module.exports) { - module.exports = BaseLaneEditor; + handleKeyDown(/* e */) { + /* override */ } + } + + // Dual export. + if (typeof window !== 'undefined') { + window.BaseLaneEditor = BaseLaneEditor; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = BaseLaneEditor; + } })(); diff --git a/public/js/features/CCPitchbendEditor.js b/public/js/features/CCPitchbendEditor.js index 87a85dac4..3c5d93a67 100644 --- a/public/js/features/CCPitchbendEditor.js +++ b/public/js/features/CCPitchbendEditor.js @@ -24,73 +24,79 @@ */ class CCPitchbendEditor extends BaseLaneEditor { - constructor(container, options = {}) { - super(container, options); - - this.events = []; - - this.currentCC = 'cc1'; // 'cc1'..'cc77', 'pitchbend', 'aftertouch', 'polyAftertouch' - this.currentNote = null; // For poly aftertouch - this.drawDensityMultiplier = 1; - - // Pixel cache for selection-rect preview (same pattern as - // Velocity — selectionStart + last mouse pos). - this.lastMouseX = undefined; - this.lastMouseY = undefined; - this.selectionStart = null; - - this.init(); - - // The base's createUI() adds canvas + element. We need to layer - // the tooltip overlay on top, in the same parent. - this._installTooltip(); - - // Sub-feature: Canvas paint pipeline (audit §1.3). - this.renderer = typeof CCPitchbendEditorRenderer !== 'undefined' - ? new CCPitchbendEditorRenderer(this) - : null; - // Sub-feature: mouse + keyboard interactions (audit §1.3). - this.interactions = typeof CCPitchbendEditorInteractions !== 'undefined' - ? new CCPitchbendEditorInteractions(this) - : null; + constructor(container, options = {}) { + super(container, options); + + this.events = []; + + this.currentCC = 'cc1'; // 'cc1'..'cc77', 'pitchbend', 'aftertouch', 'polyAftertouch' + this.currentNote = null; // For poly aftertouch + this.drawDensityMultiplier = 1; + + // Pixel cache for selection-rect preview (same pattern as + // Velocity — selectionStart + last mouse pos). + this.lastMouseX = undefined; + this.lastMouseY = undefined; + this.selectionStart = null; + + this.init(); + + // The base's createUI() adds canvas + element. We need to layer + // the tooltip overlay on top, in the same parent. + this._installTooltip(); + + // Sub-feature: Canvas paint pipeline (audit §1.3). + this.renderer = + typeof CCPitchbendEditorRenderer !== 'undefined' ? new CCPitchbendEditorRenderer(this) : null; + // Sub-feature: mouse + keyboard interactions (audit §1.3). + this.interactions = + typeof CCPitchbendEditorInteractions !== 'undefined' + ? new CCPitchbendEditorInteractions(this) + : null; + } + + // ----------------------------------------------------------------- + // Back-compat alias + // ----------------------------------------------------------------- + get selectedEvents() { + return this.selectedIds; + } + + // ================================================================= + // BaseLaneEditor hooks + // ================================================================= + + _className() { + return 'cc-pitchbend-editor'; + } + _dataField() { + return 'events'; + } + _idOf(item) { + return item ? item.id : undefined; + } + + _valueToY(value) { + const margin = 6; + const drawH = this.canvas.height - margin * 2; + const normalized = this.currentCC === 'pitchbend' ? (value + 8192) / 16384 : value / 127; + return margin + drawH - normalized * drawH; + } + + _yToValue(y) { + const margin = 6; + const drawH = this.canvas.height - margin * 2; + const normalized = 1 - (y - margin) / drawH; + if (this.currentCC === 'pitchbend') { + return Math.max(-8192, Math.min(8191, Math.round(normalized * 16384 - 8192))); } + return Math.max(0, Math.min(127, Math.round(normalized * 127))); + } - // ----------------------------------------------------------------- - // Back-compat alias - // ----------------------------------------------------------------- - get selectedEvents() { return this.selectedIds; } - - // ================================================================= - // BaseLaneEditor hooks - // ================================================================= - - _className() { return 'cc-pitchbend-editor'; } - _dataField() { return 'events'; } - _idOf(item) { return item ? item.id : undefined; } - - _valueToY(value) { - const margin = 6; - const drawH = this.canvas.height - margin * 2; - const normalized = this.currentCC === 'pitchbend' - ? (value + 8192) / 16384 - : value / 127; - return margin + drawH - (normalized * drawH); - } - - _yToValue(y) { - const margin = 6; - const drawH = this.canvas.height - margin * 2; - const normalized = 1 - ((y - margin) / drawH); - if (this.currentCC === 'pitchbend') { - return Math.max(-8192, Math.min(8191, Math.round(normalized * 16384 - 8192))); - } - return Math.max(0, Math.min(127, Math.round(normalized * 127))); - } - - _installTooltip() { - this.tooltip = document.createElement('div'); - this.tooltip.className = 'cc-editor-tooltip'; - this.tooltip.style.cssText = ` + _installTooltip() { + this.tooltip = document.createElement('div'); + this.tooltip.className = 'cc-editor-tooltip'; + this.tooltip.style.cssText = ` position: absolute; background: rgba(0,0,0,0.85); color: #fff; @@ -103,302 +109,349 @@ class CCPitchbendEditor extends BaseLaneEditor { z-index: 10; white-space: nowrap; `; - // Layer it inside the editor element (same parent as the canvas). - if (this.element) this.element.appendChild(this.tooltip); - } - - cancelInteractions() { - super.cancelInteractions(); - this.selectionStart = null; - this.lastMouseX = undefined; - this.lastMouseY = undefined; - } - - // ================================================================= - // Filter setters - // ================================================================= - - setCC(ccType) { - this.currentCC = ccType; - this.cancelInteractions(); - this.gridDirty = true; // Value-axis labels differ CC vs PB. - this.renderThrottled(); + // Layer it inside the editor element (same parent as the canvas). + if (this.element) this.element.appendChild(this.tooltip); + } + + cancelInteractions() { + super.cancelInteractions(); + this.selectionStart = null; + this.lastMouseX = undefined; + this.lastMouseY = undefined; + } + + // ================================================================= + // Filter setters + // ================================================================= + + setCC(ccType) { + this.currentCC = ccType; + this.cancelInteractions(); + this.gridDirty = true; // Value-axis labels differ CC vs PB. + this.renderThrottled(); + } + + setChannel(channel) { + this.currentChannel = channel; + this.cancelInteractions(); + this.renderThrottled(); + } + + setNote(note) { + this.currentNote = note; + this.cancelInteractions(); + this.renderThrottled(); + } + + setDrawDensity(multiplier) { + this.drawDensityMultiplier = multiplier; + } + + // ================================================================= + // Value clamping (depends on currentCC) + // ================================================================= + + clampValue(value) { + return this.currentCC === 'pitchbend' + ? Math.max(-8192, Math.min(8191, value)) + : Math.max(0, Math.min(127, value)); + } + + // ================================================================= + // Event mutations + // ================================================================= + + addEvent(ticks, value, channel = this.currentChannel, autoSave = true) { + const snappedTicks = this.snapToGrid(ticks); + const existing = this.events.find( + (e) => e.ticks === snappedTicks && e.type === this.currentCC && e.channel === channel + ); + if (existing) { + existing.value = this.clampValue(value); + if (autoSave) this.renderThrottled(); + return existing; } - setChannel(channel) { - this.currentChannel = channel; - this.cancelInteractions(); - this.renderThrottled(); + const event = { + type: this.currentCC, + ticks: snappedTicks, + value: this.clampValue(value), + channel: channel, + id: Date.now() + Math.random() + }; + if (this.currentCC === 'polyAftertouch' && this.currentNote !== null) { + event.note = this.currentNote; } + this.events.push(event); - setNote(note) { - this.currentNote = note; - this.cancelInteractions(); - this.renderThrottled(); + if (autoSave) { + this.saveState(); + this.renderThrottled(); } - - setDrawDensity(multiplier) { - this.drawDensityMultiplier = multiplier; + return event; + } + + removeEvents(eventIds) { + this.events = this.events.filter((e) => !eventIds.includes(e.id)); + this.selectedIds.clear(); + this.saveState(); + this.renderThrottled(); + } + + moveEvents(eventIds, deltaTicks, deltaValue) { + eventIds.forEach((id) => { + const event = this.events.find((e) => e.id === id); + if (event) { + event.ticks = Math.max(0, this.snapToGrid(event.ticks + deltaTicks)); + event.value = this.clampValue(event.value + deltaValue); + } + }); + this.saveState(); + this.renderThrottled(); + } + + deleteSelected() { + if (this.selectedIds.size === 0) return; + this.events = this.events.filter((event) => !this.selectedIds.has(event.id)); + this.selectedIds.clear(); + this.saveState(); + if (typeof this.options.onChange === 'function') { + try { + this.options.onChange(); + } catch (_) { + /* best-effort */ + } } - - // ================================================================= - // Value clamping (depends on currentCC) - // ================================================================= - - clampValue(value) { - return this.currentCC === 'pitchbend' - ? Math.max(-8192, Math.min(8191, value)) - : Math.max(0, Math.min(127, value)); + this.renderThrottled(); + } + + // ================================================================= + // Filtering + // ================================================================= + + getFilteredEvents() { + return this.events.filter((event) => { + if (event.type !== this.currentCC || event.channel !== this.currentChannel) return false; + if (this.currentCC === 'polyAftertouch' && this.currentNote !== null) { + return event.note === this.currentNote; + } + return true; + }); + } + + _getVisibleData() { + return this.getFilteredEvents(); + } + + // ================================================================= + // Hit-test + // ================================================================= + + getEventAtPosition(x, y, threshold = 5) { + return this.getFilteredEvents().find((event) => { + const ex = this.ticksToX(event.ticks); + const ey = this._valueToY(event.value); + return Math.abs(ex - x) <= threshold && Math.abs(ey - y) <= threshold; + }); + } + + selectInRect(x1, y1, x2, y2) { + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + const top = Math.min(y1, y2); + const bottom = Math.max(y1, y2); + this.getFilteredEvents().forEach((event) => { + const ex = this.ticksToX(event.ticks); + const ey = this._valueToY(event.value); + if (ex >= left && ex <= right && ey >= top && ey <= bottom) { + this.selectedIds.add(event.id); + } + }); + } + + selectAll() { + this.selectedIds.clear(); + this.getFilteredEvents().forEach((event) => this.selectedIds.add(event.id)); + this.renderThrottled(); + } + + // ================================================================= + // Tool handlers + // ================================================================= + + // Delegates to interactions sub-feature (extracted per audit §1.3) + handleMouseDown(e) { + return this.interactions?.handleMouseDown(e); + } + handleMouseMove(e) { + return this.interactions?.handleMouseMove(e); + } + handleMouseUp(e) { + return this.interactions?.handleMouseUp(e); + } + handleMouseLeave(e) { + return this.interactions?.handleMouseLeave(e); + } + handleKeyDown(e) { + return this.interactions?.handleKeyDown(e); + } + + updateTooltip(x, y, ticks, value) { + if (!this.tooltip) return; + let valueStr; + switch (this.currentCC) { + case 'pitchbend': + valueStr = `PB: ${value}`; + break; + case 'aftertouch': + valueStr = `AT: ${value}`; + break; + case 'polyAftertouch': + valueStr = `PAT: ${value}`; + break; + default: + valueStr = `Val: ${value}`; } - - // ================================================================= - // Event mutations - // ================================================================= - - addEvent(ticks, value, channel = this.currentChannel, autoSave = true) { - const snappedTicks = this.snapToGrid(ticks); - const existing = this.events.find(e => - e.ticks === snappedTicks && - e.type === this.currentCC && - e.channel === channel - ); - if (existing) { - existing.value = this.clampValue(value); - if (autoSave) this.renderThrottled(); - return existing; - } - - const event = { - type: this.currentCC, - ticks: snappedTicks, - value: this.clampValue(value), - channel: channel, - id: Date.now() + Math.random() - }; - if (this.currentCC === 'polyAftertouch' && this.currentNote !== null) { - event.note = this.currentNote; - } - this.events.push(event); - - if (autoSave) { - this.saveState(); - this.renderThrottled(); - } - return event; + const ppq = this.options.timebase || 480; + const beat = Math.floor(ticks / ppq); + const measure = Math.floor(beat / 4) + 1; + const beatInMeasure = (beat % 4) + 1; + const tickInBeat = ticks % ppq; + const timeStr = `${measure}:${beatInMeasure}:${String(tickInBeat).padStart(3, '0')}`; + + this.tooltip.textContent = `${timeStr} ${valueStr}`; + this.tooltip.style.display = 'block'; + this.tooltip.style.left = `${x + 12}px`; + this.tooltip.style.top = `${y - 24}px`; + } + + // ================================================================= + // Line creation with curves + // ================================================================= + + createLine(startTicks, startValue, endTicks, endValue) { + const minTicks = Math.min(startTicks, endTicks); + const maxTicks = Math.max(startTicks, endTicks); + const ticksRange = maxTicks - minTicks; + const valueRange = endValue - startValue; + + for (let t = minTicks; t <= maxTicks; t += this.options.grid) { + const progress = ticksRange > 0 ? (t - minTicks) / ticksRange : 0; + const curveProgress = this.applyCurve(progress); + const value = Math.round(startValue + valueRange * curveProgress); + this.addEvent(t, value, this.currentChannel, false); } - removeEvents(eventIds) { - this.events = this.events.filter(e => !eventIds.includes(e.id)); - this.selectedIds.clear(); - this.saveState(); - this.renderThrottled(); + const lastGridTick = + Math.floor((maxTicks - minTicks) / this.options.grid) * this.options.grid + minTicks; + if (lastGridTick < maxTicks) { + this.addEvent( + maxTicks, + Math.round(startValue + valueRange * (ticksRange > 0 ? 1 : 0)), + this.currentChannel, + false + ); } - moveEvents(eventIds, deltaTicks, deltaValue) { - eventIds.forEach(id => { - const event = this.events.find(e => e.id === id); - if (event) { - event.ticks = Math.max(0, this.snapToGrid(event.ticks + deltaTicks)); - event.value = this.clampValue(event.value + deltaValue); - } - }); - this.saveState(); - this.renderThrottled(); + this.saveState(); + this.renderThrottled(); + } + + // ================================================================= + // Sync + // ================================================================= + + syncWith(pianoRoll) { + const oldXRange = this.options.xrange; + const oldXOffset = this.options.xoffset; + const oldGrid = this.options.grid; + + this.options.xrange = pianoRoll.xrange; + this.options.xoffset = pianoRoll.xoffset; + this.options.grid = pianoRoll.grid; + this.options.timebase = pianoRoll.timebase; + + if ( + oldXRange !== this.options.xrange || + oldXOffset !== this.options.xoffset || + oldGrid !== this.options.grid + ) { + this.gridDirty = true; } - - deleteSelected() { - if (this.selectedIds.size === 0) return; - this.events = this.events.filter(event => !this.selectedIds.has(event.id)); - this.selectedIds.clear(); - this.saveState(); - if (typeof this.options.onChange === 'function') { - try { this.options.onChange(); } catch (_) { /* best-effort */ } - } - this.renderThrottled(); + this.renderThrottled(); + } + + // ================================================================= + // Import / Export + // ================================================================= + + loadEvents(events) { + this.events = events.map((e) => ({ + ...e, + id: e.id || Date.now() + Math.random() + })); + // Initialize history without triggering onChange (loading existing + // events is not a user modification). + this.history = [this._snapshotData()]; + this.historyIndex = 0; + this.renderThrottled(); + } + + getEvents() { + return this.events; + } + + clear() { + this.events = []; + this.selectedIds.clear(); + this.history = [this._snapshotData()]; + this.historyIndex = 0; + this.renderThrottled(); + if (typeof this.options.onChange === 'function') { + try { + this.options.onChange(); + } catch (_) { + /* best-effort */ + } } - - // ================================================================= - // Filtering - // ================================================================= - - getFilteredEvents() { - return this.events.filter(event => { - if (event.type !== this.currentCC || event.channel !== this.currentChannel) return false; - if (this.currentCC === 'polyAftertouch' && this.currentNote !== null) { - return event.note === this.currentNote; - } - return true; - }); - } - - _getVisibleData() { return this.getFilteredEvents(); } - - // ================================================================= - // Hit-test - // ================================================================= - - getEventAtPosition(x, y, threshold = 5) { - return this.getFilteredEvents().find(event => { - const ex = this.ticksToX(event.ticks); - const ey = this._valueToY(event.value); - return Math.abs(ex - x) <= threshold && Math.abs(ey - y) <= threshold; - }); - } - - selectInRect(x1, y1, x2, y2) { - const left = Math.min(x1, x2); - const right = Math.max(x1, x2); - const top = Math.min(y1, y2); - const bottom = Math.max(y1, y2); - this.getFilteredEvents().forEach(event => { - const ex = this.ticksToX(event.ticks); - const ey = this._valueToY(event.value); - if (ex >= left && ex <= right && ey >= top && ey <= bottom) { - this.selectedIds.add(event.id); - } - }); - } - - selectAll() { - this.selectedIds.clear(); - this.getFilteredEvents().forEach(event => this.selectedIds.add(event.id)); - this.renderThrottled(); - } - - // ================================================================= - // Tool handlers - // ================================================================= - - // Delegates to interactions sub-feature (extracted per audit §1.3) - handleMouseDown(e) { return this.interactions?.handleMouseDown(e); } - handleMouseMove(e) { return this.interactions?.handleMouseMove(e); } - handleMouseUp(e) { return this.interactions?.handleMouseUp(e); } - handleMouseLeave(e) { return this.interactions?.handleMouseLeave(e); } - handleKeyDown(e) { return this.interactions?.handleKeyDown(e); } - - updateTooltip(x, y, ticks, value) { - if (!this.tooltip) return; - let valueStr; - switch (this.currentCC) { - case 'pitchbend': valueStr = `PB: ${value}`; break; - case 'aftertouch': valueStr = `AT: ${value}`; break; - case 'polyAftertouch': valueStr = `PAT: ${value}`; break; - default: valueStr = `Val: ${value}`; - } - const ppq = this.options.timebase || 480; - const beat = Math.floor(ticks / ppq); - const measure = Math.floor(beat / 4) + 1; - const beatInMeasure = (beat % 4) + 1; - const tickInBeat = ticks % ppq; - const timeStr = `${measure}:${beatInMeasure}:${String(tickInBeat).padStart(3, '0')}`; - - this.tooltip.textContent = `${timeStr} ${valueStr}`; - this.tooltip.style.display = 'block'; - this.tooltip.style.left = `${x + 12}px`; - this.tooltip.style.top = `${y - 24}px`; - } - - // ================================================================= - // Line creation with curves - // ================================================================= - - createLine(startTicks, startValue, endTicks, endValue) { - const minTicks = Math.min(startTicks, endTicks); - const maxTicks = Math.max(startTicks, endTicks); - const ticksRange = maxTicks - minTicks; - const valueRange = endValue - startValue; - - for (let t = minTicks; t <= maxTicks; t += this.options.grid) { - const progress = ticksRange > 0 ? (t - minTicks) / ticksRange : 0; - const curveProgress = this.applyCurve(progress); - const value = Math.round(startValue + valueRange * curveProgress); - this.addEvent(t, value, this.currentChannel, false); - } - - const lastGridTick = Math.floor((maxTicks - minTicks) / this.options.grid) * this.options.grid + minTicks; - if (lastGridTick < maxTicks) { - this.addEvent(maxTicks, Math.round(startValue + valueRange * (ticksRange > 0 ? 1 : 0)), this.currentChannel, false); - } - - this.saveState(); - this.renderThrottled(); - } - - // ================================================================= - // Sync - // ================================================================= - - syncWith(pianoRoll) { - const oldXRange = this.options.xrange; - const oldXOffset = this.options.xoffset; - const oldGrid = this.options.grid; - - this.options.xrange = pianoRoll.xrange; - this.options.xoffset = pianoRoll.xoffset; - this.options.grid = pianoRoll.grid; - this.options.timebase = pianoRoll.timebase; - - if (oldXRange !== this.options.xrange - || oldXOffset !== this.options.xoffset - || oldGrid !== this.options.grid) { - this.gridDirty = true; - } - this.renderThrottled(); - } - - // ================================================================= - // Import / Export - // ================================================================= - - loadEvents(events) { - this.events = events.map(e => ({ - ...e, - id: e.id || (Date.now() + Math.random()) - })); - // Initialize history without triggering onChange (loading existing - // events is not a user modification). - this.history = [this._snapshotData()]; - this.historyIndex = 0; - this.renderThrottled(); - } - - getEvents() { return this.events; } - - clear() { - this.events = []; - this.selectedIds.clear(); - this.history = [this._snapshotData()]; - this.historyIndex = 0; - this.renderThrottled(); - if (typeof this.options.onChange === 'function') { - try { this.options.onChange(); } catch (_) { /* best-effort */ } - } - } - - // ================================================================= - // Render — overrides base.render() to add the staircase rendering, - // center line, selection rect (selectionStart + lastMouseX/Y), and - // line preview with curve interpolation. - // ================================================================= - - // Delegates to renderer sub-feature (extracted per audit §1.3) - render() { return this.renderer?.render(); } - _renderCenterLine() { return this.renderer?.renderCenterLine(); } - _renderGridToBuffer() { return this.renderer?.renderGridToBuffer(); } - _renderData() { return this.renderer?.renderData(); } - - // The legacy notifyChange contract (CC's `_doSaveState` used to emit - // `onChange()` with NO args; preserve that for back-compat). - notifyChange() { - if (typeof this.options.onChange === 'function') { - try { this.options.onChange(); } catch (_) { /* best-effort */ } - } + } + + // ================================================================= + // Render — overrides base.render() to add the staircase rendering, + // center line, selection rect (selectionStart + lastMouseX/Y), and + // line preview with curve interpolation. + // ================================================================= + + // Delegates to renderer sub-feature (extracted per audit §1.3) + render() { + return this.renderer?.render(); + } + _renderCenterLine() { + return this.renderer?.renderCenterLine(); + } + _renderGridToBuffer() { + return this.renderer?.renderGridToBuffer(); + } + _renderData() { + return this.renderer?.renderData(); + } + + // The legacy notifyChange contract (CC's `_doSaveState` used to emit + // `onChange()` with NO args; preserve that for back-compat). + notifyChange() { + if (typeof this.options.onChange === 'function') { + try { + this.options.onChange(); + } catch (_) { + /* best-effort */ + } } + } } if (typeof window !== 'undefined') { - window.CCPitchbendEditor = CCPitchbendEditor; + window.CCPitchbendEditor = CCPitchbendEditor; } if (typeof module !== 'undefined' && module.exports) { - module.exports = CCPitchbendEditor; + module.exports = CCPitchbendEditor; } diff --git a/public/js/features/CCPitchbendEditorInteractions.js b/public/js/features/CCPitchbendEditorInteractions.js index cc89d8a28..5fe7e43a3 100644 --- a/public/js/features/CCPitchbendEditorInteractions.js +++ b/public/js/features/CCPitchbendEditorInteractions.js @@ -14,161 +14,171 @@ * unchanged. */ class CCPitchbendEditorInteractions { - /** @param {CCPitchbendEditor} parent */ - constructor(parent) { - this.parent = parent; - } + /** @param {CCPitchbendEditor} parent */ + constructor(parent) { + this.parent = parent; + } - handleMouseDown(e) { - const rect = this.parent.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const ticks = this.parent.xToTicks(x); - const value = this.parent._yToValue(y); + handleMouseDown(e) { + const rect = this.parent.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const ticks = this.parent.xToTicks(x); + const value = this.parent._yToValue(y); - switch (this.parent.currentTool) { - case 'draw': - this.parent.isDrawing = true; - this.parent.lastDrawPosition = { x, y }; - this.parent.lastDrawTicks = this.parent.snapToGrid(ticks); - this.parent.addEvent(ticks, value, this.parent.currentChannel, false); - this.parent.renderThrottled(); - break; + switch (this.parent.currentTool) { + case 'draw': + this.parent.isDrawing = true; + this.parent.lastDrawPosition = { x, y }; + this.parent.lastDrawTicks = this.parent.snapToGrid(ticks); + this.parent.addEvent(ticks, value, this.parent.currentChannel, false); + this.parent.renderThrottled(); + break; - case 'line': - if (!this.parent.lineStart) { - this.parent.lineStart = { ticks, value }; - } else { - this.parent.createLine(this.parent.lineStart.ticks, this.parent.lineStart.value, ticks, value); - this.parent.lineStart = null; - } - break; + case 'line': + if (!this.parent.lineStart) { + this.parent.lineStart = { ticks, value }; + } else { + this.parent.createLine( + this.parent.lineStart.ticks, + this.parent.lineStart.value, + ticks, + value + ); + this.parent.lineStart = null; + } + break; - case 'select': { - const clicked = this.parent.getEventAtPosition(x, y); - if (clicked) { - if (e.shiftKey) { - if (this.parent.selectedIds.has(clicked.id)) this.parent.selectedIds.delete(clicked.id); - else this.parent.selectedIds.add(clicked.id); - } else { - this.parent.selectedIds.clear(); - this.parent.selectedIds.add(clicked.id); - } - this.parent.dragStart = { x, y, ticks, value }; - } else { - if (!e.shiftKey) this.parent.selectedIds.clear(); - this.parent.selectionStart = { x, y }; - } - this.parent.renderThrottled(); - break; - } + case 'select': { + const clicked = this.parent.getEventAtPosition(x, y); + if (clicked) { + if (e.shiftKey) { + if (this.parent.selectedIds.has(clicked.id)) this.parent.selectedIds.delete(clicked.id); + else this.parent.selectedIds.add(clicked.id); + } else { + this.parent.selectedIds.clear(); + this.parent.selectedIds.add(clicked.id); + } + this.parent.dragStart = { x, y, ticks, value }; + } else { + if (!e.shiftKey) this.parent.selectedIds.clear(); + this.parent.selectionStart = { x, y }; + } + this.parent.renderThrottled(); + break; + } - case 'move': { - const moveEvent = this.parent.getEventAtPosition(x, y); - if (moveEvent) { - if (!this.parent.selectedIds.has(moveEvent.id)) { - this.parent.selectedIds.clear(); - this.parent.selectedIds.add(moveEvent.id); - } - this.parent.dragStart = { x, y, ticks, value }; - } - this.parent.renderThrottled(); - break; - } + case 'move': { + const moveEvent = this.parent.getEventAtPosition(x, y); + if (moveEvent) { + if (!this.parent.selectedIds.has(moveEvent.id)) { + this.parent.selectedIds.clear(); + this.parent.selectedIds.add(moveEvent.id); + } + this.parent.dragStart = { x, y, ticks, value }; } + this.parent.renderThrottled(); + break; + } } + } - handleMouseMove(e) { - const rect = this.parent.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const ticks = this.parent.xToTicks(x); - const value = this.parent._yToValue(y); - - this.parent.lastMouseX = x; - this.parent.lastMouseY = y; - - if (this.parent.isDrawing && this.parent.currentTool === 'draw') { - const snappedTicks = this.parent.snapToGrid(ticks); - const advanced = this.parent.lastDrawTicks === null - || Math.abs(snappedTicks - this.parent.lastDrawTicks) >= this.parent.options.grid * this.parent.drawDensityMultiplier; - if (advanced) { - this.parent.addEvent(ticks, value, this.parent.currentChannel, false); - this.parent.lastDrawTicks = snappedTicks; - this.parent.lastDrawPosition = { x, y }; - this.parent.renderThrottled(); - } - } else if (this.parent.dragStart && (this.parent.currentTool === 'select' || this.parent.currentTool === 'move')) { - if (this.parent.selectedIds.size > 0) { - const deltaTicks = this.parent.xToTicks(x) - this.parent.dragStart.ticks; - const deltaValue = this.parent._yToValue(y) - this.parent.dragStart.value; - Array.from(this.parent.selectedIds).forEach(id => { - const event = this.parent.events.find(ev => ev.id === id); - if (event) { - event.ticks = Math.max(0, this.parent.snapToGrid(event.ticks + deltaTicks)); - event.value = this.parent.clampValue(event.value + deltaValue); - } - }); - this.parent.dragStart = { x, y, ticks, value }; - this.parent.renderThrottled(); - } - } else if (this.parent.selectionStart || this.parent.lineStart) { - this.parent.renderThrottled(); - } + handleMouseMove(e) { + const rect = this.parent.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const ticks = this.parent.xToTicks(x); + const value = this.parent._yToValue(y); - this.parent.updateTooltip(x, y, ticks, value); - } + this.parent.lastMouseX = x; + this.parent.lastMouseY = y; - handleMouseUp(e) { - if (this.parent.isDrawing) { - this.parent.isDrawing = false; - this.parent.lastDrawPosition = null; - this.parent.lastDrawTicks = null; - this.parent.saveState(); - } - if (this.parent.selectionStart) { - const rect = this.parent.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - this.parent.selectInRect(this.parent.selectionStart.x, this.parent.selectionStart.y, x, y); - this.parent.selectionStart = null; - } - if (this.parent.dragStart) { - this.parent.saveState(); - this.parent.dragStart = null; - } + if (this.parent.isDrawing && this.parent.currentTool === 'draw') { + const snappedTicks = this.parent.snapToGrid(ticks); + const advanced = + this.parent.lastDrawTicks === null || + Math.abs(snappedTicks - this.parent.lastDrawTicks) >= + this.parent.options.grid * this.parent.drawDensityMultiplier; + if (advanced) { + this.parent.addEvent(ticks, value, this.parent.currentChannel, false); + this.parent.lastDrawTicks = snappedTicks; + this.parent.lastDrawPosition = { x, y }; + this.parent.renderThrottled(); + } + } else if ( + this.parent.dragStart && + (this.parent.currentTool === 'select' || this.parent.currentTool === 'move') + ) { + if (this.parent.selectedIds.size > 0) { + const deltaTicks = this.parent.xToTicks(x) - this.parent.dragStart.ticks; + const deltaValue = this.parent._yToValue(y) - this.parent.dragStart.value; + Array.from(this.parent.selectedIds).forEach((id) => { + const event = this.parent.events.find((ev) => ev.id === id); + if (event) { + event.ticks = Math.max(0, this.parent.snapToGrid(event.ticks + deltaTicks)); + event.value = this.parent.clampValue(event.value + deltaValue); + } + }); + this.parent.dragStart = { x, y, ticks, value }; this.parent.renderThrottled(); + } + } else if (this.parent.selectionStart || this.parent.lineStart) { + this.parent.renderThrottled(); } - handleMouseLeave(e) { - this.handleMouseUp(e); - if (this.parent.tooltip) this.parent.tooltip.style.display = 'none'; + this.parent.updateTooltip(x, y, ticks, value); + } + + handleMouseUp(e) { + if (this.parent.isDrawing) { + this.parent.isDrawing = false; + this.parent.lastDrawPosition = null; + this.parent.lastDrawTicks = null; + this.parent.saveState(); } + if (this.parent.selectionStart) { + const rect = this.parent.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + this.parent.selectInRect(this.parent.selectionStart.x, this.parent.selectionStart.y, x, y); + this.parent.selectionStart = null; + } + if (this.parent.dragStart) { + this.parent.saveState(); + this.parent.dragStart = null; + } + this.parent.renderThrottled(); + } - handleKeyDown(e) { - if (!this.parent.element || this.parent.element.offsetParent === null) return; + handleMouseLeave(e) { + this.handleMouseUp(e); + if (this.parent.tooltip) this.parent.tooltip.style.display = 'none'; + } - if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent.selectedIds.size > 0) { - this.parent.removeEvents(Array.from(this.parent.selectedIds)); - } else if (e.key === 'Escape') { - this.parent.selectedIds.clear(); - this.parent.lineStart = null; - this.parent.renderThrottled(); - } else if (e.ctrlKey || e.metaKey) { - if (e.key === 'z') { - this.parent.undo(); - e.preventDefault(); - } else if (e.key === 'y' || (e.shiftKey && e.key === 'Z')) { - this.parent.redo(); - e.preventDefault(); - } else if (e.key === 'a') { - this.parent.selectAll(); - e.preventDefault(); - } - } + handleKeyDown(e) { + if (!this.parent.element || this.parent.element.offsetParent === null) return; + + if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent.selectedIds.size > 0) { + this.parent.removeEvents(Array.from(this.parent.selectedIds)); + } else if (e.key === 'Escape') { + this.parent.selectedIds.clear(); + this.parent.lineStart = null; + this.parent.renderThrottled(); + } else if (e.ctrlKey || e.metaKey) { + if (e.key === 'z') { + this.parent.undo(); + e.preventDefault(); + } else if (e.key === 'y' || (e.shiftKey && e.key === 'Z')) { + this.parent.redo(); + e.preventDefault(); + } else if (e.key === 'a') { + this.parent.selectAll(); + e.preventDefault(); + } } + } } if (typeof window !== 'undefined') { - window.CCPitchbendEditorInteractions = CCPitchbendEditorInteractions; + window.CCPitchbendEditorInteractions = CCPitchbendEditorInteractions; } diff --git a/public/js/features/CCPitchbendEditorRenderer.js b/public/js/features/CCPitchbendEditorRenderer.js index c4603f31c..bf9bc8be3 100644 --- a/public/js/features/CCPitchbendEditorRenderer.js +++ b/public/js/features/CCPitchbendEditorRenderer.js @@ -16,204 +16,207 @@ * so internal callers (BaseLaneEditor RAF, setCC, resize…) are unchanged. */ class CCPitchbendEditorRenderer { - /** @param {CCPitchbendEditor} parent */ - constructor(parent) { - this.parent = parent; + /** @param {CCPitchbendEditor} parent */ + constructor(parent) { + this.parent = parent; + } + + render() { + if (!this.parent.ctx || !this.parent.canvas) return; + this.parent.ctx.clearRect(0, 0, this.parent.canvas.width, this.parent.canvas.height); + + if (this.parent.gridDirty || !this.parent.gridCanvas) { + this.renderGridToBuffer(); + this.parent.gridDirty = false; } - - render() { - if (!this.parent.ctx || !this.parent.canvas) return; - this.parent.ctx.clearRect(0, 0, this.parent.canvas.width, this.parent.canvas.height); - - if (this.parent.gridDirty || !this.parent.gridCanvas) { - this.renderGridToBuffer(); - this.parent.gridDirty = false; - } - if (this.parent.gridCanvas) this.parent.ctx.drawImage(this.parent.gridCanvas, 0, 0); - - this.renderCenterLine(); - this.renderData(); - - // Selection rect preview. - if (this.parent.selectionStart && this.parent.lastMouseX !== undefined) { - this.parent.ctx.strokeStyle = '#2196F3'; - this.parent.ctx.lineWidth = 1; - this.parent.ctx.setLineDash([5, 5]); - this.parent.ctx.strokeRect(this.parent.selectionStart.x, this.parent.selectionStart.y, - this.parent.lastMouseX - this.parent.selectionStart.x, - this.parent.lastMouseY - this.parent.selectionStart.y); - this.parent.ctx.setLineDash([]); - } - - // Line preview with curve. - if (this.parent.lineStart && this.parent.lastMouseX !== undefined) { - const endTicks = this.parent.xToTicks(this.parent.lastMouseX); - const endValue = this.parent._yToValue(this.parent.lastMouseY); - this.parent.ctx.strokeStyle = '#9E9E9E'; - this.parent.ctx.lineWidth = 1; - this.parent.ctx.setLineDash([5, 5]); - this.parent.ctx.beginPath(); - const segments = 30; - for (let i = 0; i <= segments; i++) { - const t = i / segments; - const curveT = this.parent.applyCurve(t); - const ticks = this.parent.lineStart.ticks + (endTicks - this.parent.lineStart.ticks) * t; - const value = this.parent.lineStart.value + (endValue - this.parent.lineStart.value) * curveT; - const x = this.parent.ticksToX(ticks); - const y = this.parent._valueToY(value); - if (i === 0) this.parent.ctx.moveTo(x, y); - else this.parent.ctx.lineTo(x, y); - } - this.parent.ctx.stroke(); - this.parent.ctx.setLineDash([]); - } + if (this.parent.gridCanvas) this.parent.ctx.drawImage(this.parent.gridCanvas, 0, 0); + + this.renderCenterLine(); + this.renderData(); + + // Selection rect preview. + if (this.parent.selectionStart && this.parent.lastMouseX !== undefined) { + this.parent.ctx.strokeStyle = '#2196F3'; + this.parent.ctx.lineWidth = 1; + this.parent.ctx.setLineDash([5, 5]); + this.parent.ctx.strokeRect( + this.parent.selectionStart.x, + this.parent.selectionStart.y, + this.parent.lastMouseX - this.parent.selectionStart.x, + this.parent.lastMouseY - this.parent.selectionStart.y + ); + this.parent.ctx.setLineDash([]); } - renderCenterLine() { - const filteredEvents = this.parent.getFilteredEvents(); - const labelMargin = this.parent._labelMargin(); - const isDark = document.body.classList.contains('dark-mode'); - - if (this.parent.currentCC === 'pitchbend') { - this.parent.ctx.strokeStyle = isDark ? '#888' : '#667eea'; - this.parent.ctx.lineWidth = 2; - const y = this.parent._valueToY(0); - this.parent.ctx.beginPath(); - this.parent.ctx.moveTo(labelMargin, y); - this.parent.ctx.lineTo(this.parent.canvas.width, y); - this.parent.ctx.stroke(); - } else if (filteredEvents.length === 0) { - this.parent.ctx.strokeStyle = isDark ? '#666' : '#8898d8'; - this.parent.ctx.lineWidth = 2; - this.parent.ctx.setLineDash([5, 5]); - const y = this.parent._valueToY(0); - this.parent.ctx.beginPath(); - this.parent.ctx.moveTo(labelMargin, y); - this.parent.ctx.lineTo(this.parent.canvas.width, y); - this.parent.ctx.stroke(); - this.parent.ctx.setLineDash([]); - } + // Line preview with curve. + if (this.parent.lineStart && this.parent.lastMouseX !== undefined) { + const endTicks = this.parent.xToTicks(this.parent.lastMouseX); + const endValue = this.parent._yToValue(this.parent.lastMouseY); + this.parent.ctx.strokeStyle = '#9E9E9E'; + this.parent.ctx.lineWidth = 1; + this.parent.ctx.setLineDash([5, 5]); + this.parent.ctx.beginPath(); + const segments = 30; + for (let i = 0; i <= segments; i++) { + const t = i / segments; + const curveT = this.parent.applyCurve(t); + const ticks = this.parent.lineStart.ticks + (endTicks - this.parent.lineStart.ticks) * t; + const value = + this.parent.lineStart.value + (endValue - this.parent.lineStart.value) * curveT; + const x = this.parent.ticksToX(ticks); + const y = this.parent._valueToY(value); + if (i === 0) this.parent.ctx.moveTo(x, y); + else this.parent.ctx.lineTo(x, y); + } + this.parent.ctx.stroke(); + this.parent.ctx.setLineDash([]); } - - renderGridToBuffer() { - if (!this.parent.gridCtx) return; - const ctx = this.parent.gridCtx; - const labelMargin = this.parent._labelMargin(); - const isDark = document.body.classList.contains('dark-mode'); - - ctx.clearRect(0, 0, this.parent.gridCanvas.width, this.parent.gridCanvas.height); - - // Vertical (time) grid. - ctx.strokeStyle = isDark ? '#3a3a3a' : '#d4daff'; - ctx.lineWidth = 1; - const gridSize = this.parent.options.grid; - const startTick = Math.floor(this.parent.options.xoffset / gridSize) * gridSize; - const endTick = this.parent.options.xoffset + this.parent.options.xrange; - for (let t = startTick; t <= endTick; t += gridSize) { - const x = this.parent.ticksToX(t); - if (x >= 0 && x <= this.parent.gridCanvas.width) { - ctx.beginPath(); - ctx.moveTo(Math.max(x, labelMargin), 0); - ctx.lineTo(x, this.parent.gridCanvas.height); - ctx.stroke(); - } - } - - // Value-axis grid + labels. - const values = this.parent.currentCC === 'pitchbend' - ? [-8192, -4096, 0, 4096, 8191] - : [0, 32, 64, 96, 127]; - ctx.strokeStyle = isDark ? '#3a3a3a' : '#d4daff'; - ctx.lineWidth = 1; - values.forEach(value => { - const y = this.parent._valueToY(value); - ctx.beginPath(); - ctx.moveTo(labelMargin, y); - ctx.lineTo(this.parent.gridCanvas.width, y); - ctx.stroke(); - ctx.fillStyle = isDark ? '#1a1a1a' : '#f0f4ff'; - ctx.fillRect(0, y - 7, labelMargin - 2, 14); - ctx.fillStyle = isDark ? '#aaa' : '#5a6089'; - ctx.font = '11px monospace'; - ctx.textAlign = 'right'; - ctx.fillText(value.toString(), labelMargin - 5, y + 4); - }); - - // Label-area separator. - ctx.strokeStyle = isDark ? '#555' : '#b0b8e8'; - ctx.lineWidth = 2; + } + + renderCenterLine() { + const filteredEvents = this.parent.getFilteredEvents(); + const labelMargin = this.parent._labelMargin(); + const isDark = document.body.classList.contains('dark-mode'); + + if (this.parent.currentCC === 'pitchbend') { + this.parent.ctx.strokeStyle = isDark ? '#888' : '#667eea'; + this.parent.ctx.lineWidth = 2; + const y = this.parent._valueToY(0); + this.parent.ctx.beginPath(); + this.parent.ctx.moveTo(labelMargin, y); + this.parent.ctx.lineTo(this.parent.canvas.width, y); + this.parent.ctx.stroke(); + } else if (filteredEvents.length === 0) { + this.parent.ctx.strokeStyle = isDark ? '#666' : '#8898d8'; + this.parent.ctx.lineWidth = 2; + this.parent.ctx.setLineDash([5, 5]); + const y = this.parent._valueToY(0); + this.parent.ctx.beginPath(); + this.parent.ctx.moveTo(labelMargin, y); + this.parent.ctx.lineTo(this.parent.canvas.width, y); + this.parent.ctx.stroke(); + this.parent.ctx.setLineDash([]); + } + } + + renderGridToBuffer() { + if (!this.parent.gridCtx) return; + const ctx = this.parent.gridCtx; + const labelMargin = this.parent._labelMargin(); + const isDark = document.body.classList.contains('dark-mode'); + + ctx.clearRect(0, 0, this.parent.gridCanvas.width, this.parent.gridCanvas.height); + + // Vertical (time) grid. + ctx.strokeStyle = isDark ? '#3a3a3a' : '#d4daff'; + ctx.lineWidth = 1; + const gridSize = this.parent.options.grid; + const startTick = Math.floor(this.parent.options.xoffset / gridSize) * gridSize; + const endTick = this.parent.options.xoffset + this.parent.options.xrange; + for (let t = startTick; t <= endTick; t += gridSize) { + const x = this.parent.ticksToX(t); + if (x >= 0 && x <= this.parent.gridCanvas.width) { ctx.beginPath(); - ctx.moveTo(labelMargin, 0); - ctx.lineTo(labelMargin, this.parent.gridCanvas.height); + ctx.moveTo(Math.max(x, labelMargin), 0); + ctx.lineTo(x, this.parent.gridCanvas.height); ctx.stroke(); - - ctx.textAlign = 'left'; + } } - renderData() { - const allEvents = this.parent.getFilteredEvents().sort((a, b) => a.ticks - b.ticks); - - // Viewport culling (+1 boundary event so connecting line stays). - const visStart = this.parent.options.xoffset; - const visEnd = this.parent.options.xoffset + this.parent.options.xrange; - let firstVisible = 0; - let lastVisible = allEvents.length - 1; - for (let i = 0; i < allEvents.length; i++) { - if (allEvents[i].ticks >= visStart) { - firstVisible = Math.max(0, i - 1); - break; - } - } - for (let i = allEvents.length - 1; i >= 0; i--) { - if (allEvents[i].ticks <= visEnd) { - lastVisible = Math.min(allEvents.length - 1, i + 1); - break; - } - } - const events = allEvents.slice(firstVisible, lastVisible + 1); - - // Staircase polyline. - if (events.length > 1) { - this.parent.ctx.strokeStyle = '#4CAF50'; - this.parent.ctx.lineWidth = 2; - this.parent.ctx.beginPath(); - events.forEach((event, i) => { - const x = this.parent.ticksToX(event.ticks); - const y = this.parent._valueToY(event.value); - if (i === 0) { - this.parent.ctx.moveTo(x, y); - } else { - const prev = events[i - 1]; - const prevY = this.parent._valueToY(prev.value); - this.parent.ctx.lineTo(x, prevY); - this.parent.ctx.lineTo(x, y); - } - }); - this.parent.ctx.stroke(); - } else if (events.length === 1) { - this.parent.ctx.strokeStyle = '#4CAF50'; - this.parent.ctx.lineWidth = 2; - this.parent.ctx.beginPath(); - const x = this.parent.ticksToX(events[0].ticks); - const y = this.parent._valueToY(events[0].value); - this.parent.ctx.moveTo(x, y); - this.parent.ctx.lineTo(this.parent.canvas.width, y); - this.parent.ctx.stroke(); + // Value-axis grid + labels. + const values = + this.parent.currentCC === 'pitchbend' ? [-8192, -4096, 0, 4096, 8191] : [0, 32, 64, 96, 127]; + ctx.strokeStyle = isDark ? '#3a3a3a' : '#d4daff'; + ctx.lineWidth = 1; + values.forEach((value) => { + const y = this.parent._valueToY(value); + ctx.beginPath(); + ctx.moveTo(labelMargin, y); + ctx.lineTo(this.parent.gridCanvas.width, y); + ctx.stroke(); + ctx.fillStyle = isDark ? '#1a1a1a' : '#f0f4ff'; + ctx.fillRect(0, y - 7, labelMargin - 2, 14); + ctx.fillStyle = isDark ? '#aaa' : '#5a6089'; + ctx.font = '11px monospace'; + ctx.textAlign = 'right'; + ctx.fillText(value.toString(), labelMargin - 5, y + 4); + }); + + // Label-area separator. + ctx.strokeStyle = isDark ? '#555' : '#b0b8e8'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(labelMargin, 0); + ctx.lineTo(labelMargin, this.parent.gridCanvas.height); + ctx.stroke(); + + ctx.textAlign = 'left'; + } + + renderData() { + const allEvents = this.parent.getFilteredEvents().sort((a, b) => a.ticks - b.ticks); + + // Viewport culling (+1 boundary event so connecting line stays). + const visStart = this.parent.options.xoffset; + const visEnd = this.parent.options.xoffset + this.parent.options.xrange; + let firstVisible = 0; + let lastVisible = allEvents.length - 1; + for (let i = 0; i < allEvents.length; i++) { + if (allEvents[i].ticks >= visStart) { + firstVisible = Math.max(0, i - 1); + break; + } + } + for (let i = allEvents.length - 1; i >= 0; i--) { + if (allEvents[i].ticks <= visEnd) { + lastVisible = Math.min(allEvents.length - 1, i + 1); + break; + } + } + const events = allEvents.slice(firstVisible, lastVisible + 1); + + // Staircase polyline. + if (events.length > 1) { + this.parent.ctx.strokeStyle = '#4CAF50'; + this.parent.ctx.lineWidth = 2; + this.parent.ctx.beginPath(); + events.forEach((event, i) => { + const x = this.parent.ticksToX(event.ticks); + const y = this.parent._valueToY(event.value); + if (i === 0) { + this.parent.ctx.moveTo(x, y); + } else { + const prev = events[i - 1]; + const prevY = this.parent._valueToY(prev.value); + this.parent.ctx.lineTo(x, prevY); + this.parent.ctx.lineTo(x, y); } - - // Points. - events.forEach(event => { - const x = this.parent.ticksToX(event.ticks); - const y = this.parent._valueToY(event.value); - const isSelected = this.parent.selectedIds.has(event.id); - this.parent.ctx.fillStyle = isSelected ? '#FFC107' : '#4CAF50'; - this.parent.ctx.beginPath(); - this.parent.ctx.arc(x, y, isSelected ? 5 : 3, 0, 2 * Math.PI); - this.parent.ctx.fill(); - }); + }); + this.parent.ctx.stroke(); + } else if (events.length === 1) { + this.parent.ctx.strokeStyle = '#4CAF50'; + this.parent.ctx.lineWidth = 2; + this.parent.ctx.beginPath(); + const x = this.parent.ticksToX(events[0].ticks); + const y = this.parent._valueToY(events[0].value); + this.parent.ctx.moveTo(x, y); + this.parent.ctx.lineTo(this.parent.canvas.width, y); + this.parent.ctx.stroke(); } + + // Points. + events.forEach((event) => { + const x = this.parent.ticksToX(event.ticks); + const y = this.parent._valueToY(event.value); + const isSelected = this.parent.selectedIds.has(event.id); + this.parent.ctx.fillStyle = isSelected ? '#FFC107' : '#4CAF50'; + this.parent.ctx.beginPath(); + this.parent.ctx.arc(x, y, isSelected ? 5 : 3, 0, 2 * Math.PI); + this.parent.ctx.fill(); + }); + } } if (typeof window !== 'undefined') { - window.CCPitchbendEditorRenderer = CCPitchbendEditorRenderer; + window.CCPitchbendEditorRenderer = CCPitchbendEditorRenderer; } diff --git a/public/js/features/CanvasRenderer.js b/public/js/features/CanvasRenderer.js index 5f63131b3..a06044a76 100644 --- a/public/js/features/CanvasRenderer.js +++ b/public/js/features/CanvasRenderer.js @@ -27,247 +27,270 @@ */ (function () { - 'use strict'; - - /** Soft caps for {@link CanvasRenderer.setZoom}. */ - const ZOOM_MIN = 0.5; - const ZOOM_MAX = 20; - - /** Default undo-history depth. */ - const DEFAULT_MAX_UNDO = 20; - - class CanvasRenderer { - /** - * @param {HTMLCanvasElement} canvas - * @param {Object} [options] - * @param {number} [options.headerWidth=0] - Left margin reserved for labels. - * @param {number} [options.topMargin=0] - Top margin (ruler). - * @param {number} [options.ticksPerBeat=480] - * @param {number} [options.beatsPerMeasure=4] - * @param {Function} [options.onScrollChange] - Notified after scrollX/zoom changes. - * @param {number} [options.maxUndo=DEFAULT_MAX_UNDO] - */ - constructor(canvas, options = {}) { - if (!canvas) throw new Error('CanvasRenderer: canvas is required'); - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - - // Layout - this.headerWidth = options.headerWidth ?? 0; - this.topMargin = options.topMargin ?? 0; - this.ticksPerBeat = options.ticksPerBeat ?? 480; - this.beatsPerMeasure = options.beatsPerMeasure ?? 4; - - // Scroll / zoom - this.scrollX = 0; - this.ticksPerPixel = 2; // px per tick → tickToX divides by this - - // Playhead - this.playheadTick = 0; - - // RAF coalescing - this._redrawScheduled = false; - this._rafId = 0; - - // Interaction state (concrete handlers are subclass-defined) - this._isDragging = false; - this._dragStart = null; - this._dragMode = null; - - // Selection - this.selectedEvents = new Set(); - this.selectionRect = null; - - // Undo / redo - this._undoStack = []; - this._redoStack = []; - this._maxUndoSize = options.maxUndo ?? DEFAULT_MAX_UNDO; - - // Clipboard - this._clipboard = []; - - // Theme tokens — subclass populates via updateTheme() if needed. - this.colors = {}; - - // External callback for scroll/zoom changes. - this.onScrollChange = options.onScrollChange ?? null; - - // Bind input handlers — subclass MUST implement them. - this._onMouseDown = (e) => this._handleMouseDown(e); - this._onMouseMove = (e) => this._handleMouseMove(e); - this._onMouseUp = (e) => this._handleMouseUp(e); - this._onDblClick = (e) => this._handleDblClick(e); - this._onWheel = (e) => this._handleWheel(e); - this._onContextMenu = (e) => { - // Middle-click guard preserved from legacy renderers — some - // mice translate auxclick into a context menu request. - if (e.button === 1) e.preventDefault(); - }; - - this._attachListeners(); - } + 'use strict'; + + /** Soft caps for {@link CanvasRenderer.setZoom}. */ + const ZOOM_MIN = 0.5; + const ZOOM_MAX = 20; + + /** Default undo-history depth. */ + const DEFAULT_MAX_UNDO = 20; + + class CanvasRenderer { + /** + * @param {HTMLCanvasElement} canvas + * @param {Object} [options] + * @param {number} [options.headerWidth=0] - Left margin reserved for labels. + * @param {number} [options.topMargin=0] - Top margin (ruler). + * @param {number} [options.ticksPerBeat=480] + * @param {number} [options.beatsPerMeasure=4] + * @param {Function} [options.onScrollChange] - Notified after scrollX/zoom changes. + * @param {number} [options.maxUndo=DEFAULT_MAX_UNDO] + */ + constructor(canvas, options = {}) { + if (!canvas) throw new Error('CanvasRenderer: canvas is required'); + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + // Layout + this.headerWidth = options.headerWidth ?? 0; + this.topMargin = options.topMargin ?? 0; + this.ticksPerBeat = options.ticksPerBeat ?? 480; + this.beatsPerMeasure = options.beatsPerMeasure ?? 4; + + // Scroll / zoom + this.scrollX = 0; + this.ticksPerPixel = 2; // px per tick → tickToX divides by this + + // Playhead + this.playheadTick = 0; + + // RAF coalescing + this._redrawScheduled = false; + this._rafId = 0; + + // Interaction state (concrete handlers are subclass-defined) + this._isDragging = false; + this._dragStart = null; + this._dragMode = null; + + // Selection + this.selectedEvents = new Set(); + this.selectionRect = null; + + // Undo / redo + this._undoStack = []; + this._redoStack = []; + this._maxUndoSize = options.maxUndo ?? DEFAULT_MAX_UNDO; + + // Clipboard + this._clipboard = []; + + // Theme tokens — subclass populates via updateTheme() if needed. + this.colors = {}; + + // External callback for scroll/zoom changes. + this.onScrollChange = options.onScrollChange ?? null; + + // Bind input handlers — subclass MUST implement them. + this._onMouseDown = (e) => this._handleMouseDown(e); + this._onMouseMove = (e) => this._handleMouseMove(e); + this._onMouseUp = (e) => this._handleMouseUp(e); + this._onDblClick = (e) => this._handleDblClick(e); + this._onWheel = (e) => this._handleWheel(e); + this._onContextMenu = (e) => { + // Middle-click guard preserved from legacy renderers — some + // mice translate auxclick into a context menu request. + if (e.button === 1) e.preventDefault(); + }; + + this._attachListeners(); + } - // ================================================================= - // Listener wiring - // ================================================================= - - _attachListeners() { - this.canvas.addEventListener('mousedown', this._onMouseDown); - this.canvas.addEventListener('mousemove', this._onMouseMove); - this.canvas.addEventListener('mouseup', this._onMouseUp); - this.canvas.addEventListener('dblclick', this._onDblClick); - this.canvas.addEventListener('wheel', this._onWheel, { passive: false }); - if (this._attachContextMenu()) { - this.canvas.addEventListener('auxclick', this._onContextMenu); - this.canvas.addEventListener('contextmenu', this._onContextMenu); - } - } + // ================================================================= + // Listener wiring + // ================================================================= + + _attachListeners() { + this.canvas.addEventListener('mousedown', this._onMouseDown); + this.canvas.addEventListener('mousemove', this._onMouseMove); + this.canvas.addEventListener('mouseup', this._onMouseUp); + this.canvas.addEventListener('dblclick', this._onDblClick); + this.canvas.addEventListener('wheel', this._onWheel, { passive: false }); + if (this._attachContextMenu()) { + this.canvas.addEventListener('auxclick', this._onContextMenu); + this.canvas.addEventListener('contextmenu', this._onContextMenu); + } + } - /** - * @returns {boolean} `true` to attach auxclick/contextmenu. - * Subclasses that need a middle-click handler override this. - */ - _attachContextMenu() { return false; } + /** + * @returns {boolean} `true` to attach auxclick/contextmenu. + * Subclasses that need a middle-click handler override this. + */ + _attachContextMenu() { + return false; + } - // ================================================================= - // Scroll / zoom setters - // ================================================================= + // ================================================================= + // Scroll / zoom setters + // ================================================================= - setScrollX(tickOffset) { - this.scrollX = Math.max(0, tickOffset); - this.requestRedraw(); - this._notifyScrollChange(); - } + setScrollX(tickOffset) { + this.scrollX = Math.max(0, tickOffset); + this.requestRedraw(); + this._notifyScrollChange(); + } - setZoom(ticksPerPixel) { - this.ticksPerPixel = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, ticksPerPixel)); - this.requestRedraw(); - this._notifyScrollChange(); - } + setZoom(ticksPerPixel) { + this.ticksPerPixel = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, ticksPerPixel)); + this.requestRedraw(); + this._notifyScrollChange(); + } - setPlayhead(tick) { - this.playheadTick = tick; - this.requestRedraw(); - } + setPlayhead(tick) { + this.playheadTick = tick; + this.requestRedraw(); + } - setTimeSignature(ticksPerBeat, beatsPerMeasure) { - this.ticksPerBeat = ticksPerBeat; - this.beatsPerMeasure = beatsPerMeasure; - this.requestRedraw(); - } + setTimeSignature(ticksPerBeat, beatsPerMeasure) { + this.ticksPerBeat = ticksPerBeat; + this.beatsPerMeasure = beatsPerMeasure; + this.requestRedraw(); + } - _notifyScrollChange() { - if (typeof this.onScrollChange === 'function') { - try { this.onScrollChange(); } - catch (_) { /* best-effort */ } - } + _notifyScrollChange() { + if (typeof this.onScrollChange === 'function') { + try { + this.onScrollChange(); + } catch (_) { + /* best-effort */ } + } + } - // ================================================================= - // Selection helpers - // ================================================================= + // ================================================================= + // Selection helpers + // ================================================================= - selectEvent(index) { this.selectedEvents.add(index); } - deselectEvent(index) { this.selectedEvents.delete(index); } - clearSelection() { this.selectedEvents.clear(); } + selectEvent(index) { + this.selectedEvents.add(index); + } + deselectEvent(index) { + this.selectedEvents.delete(index); + } + clearSelection() { + this.selectedEvents.clear(); + } - // ================================================================= - // Coordinate conversion. Subclasses needing sub-tick precision - // (WindMelody) override `_xToTick`. - // ================================================================= + // ================================================================= + // Coordinate conversion. Subclasses needing sub-tick precision + // (WindMelody) override `_xToTick`. + // ================================================================= - _tickToX(tick) { - return this.headerWidth + (tick - this.scrollX) / this.ticksPerPixel; - } + _tickToX(tick) { + return this.headerWidth + (tick - this.scrollX) / this.ticksPerPixel; + } - _xToTick(x) { - return Math.round((x - this.headerWidth) * this.ticksPerPixel + this.scrollX); - } + _xToTick(x) { + return Math.round((x - this.headerWidth) * this.ticksPerPixel + this.scrollX); + } - // ================================================================= - // RAF render coalescing - // ================================================================= - - requestRedraw() { - if (this._redrawScheduled) return; - this._redrawScheduled = true; - this._rafId = requestAnimationFrame(() => { - this._redrawScheduled = false; - this._rafId = 0; - this.redraw(); - }); - } + // ================================================================= + // RAF render coalescing + // ================================================================= + + requestRedraw() { + if (this._redrawScheduled) return; + this._redrawScheduled = true; + this._rafId = requestAnimationFrame(() => { + this._redrawScheduled = false; + this._rafId = 0; + this.redraw(); + }); + } - /** Paint the canvas. MUST be overridden by the subclass. */ - redraw() { - throw new Error('CanvasRenderer: subclass must implement redraw()'); - } + /** Paint the canvas. MUST be overridden by the subclass. */ + redraw() { + throw new Error('CanvasRenderer: subclass must implement redraw()'); + } - // ================================================================= - // Canvas size - // ================================================================= - - /** - * Default `resize` assigns the canvas size and triggers a redraw. - * Subclasses that need to recompute layout (DrumGrid scrollbar, - * Tablature line spacing) can override and call `super.resize()`. - */ - resize(width, height) { - if (width !== undefined && height !== undefined) { - this.canvas.width = width; - this.canvas.height = height; - } - this.requestRedraw(); - } + // ================================================================= + // Canvas size + // ================================================================= + + /** + * Default `resize` assigns the canvas size and triggers a redraw. + * Subclasses that need to recompute layout (DrumGrid scrollbar, + * Tablature line spacing) can override and call `super.resize()`. + */ + resize(width, height) { + if (width !== undefined && height !== undefined) { + this.canvas.width = width; + this.canvas.height = height; + } + this.requestRedraw(); + } - // ================================================================= - // Abstract input handlers — subclass MUST implement. - // ================================================================= - - _handleMouseDown(/* e */) { /* override */ } - _handleMouseMove(/* e */) { /* override */ } - _handleMouseUp(/* e */) { /* override */ } - _handleDblClick(/* e */) { /* override */ } - _handleWheel(/* e */) { /* override */ } - - // ================================================================= - // Vertical-zoom hook — subclass implements (row / line / note). - // ================================================================= - - setVerticalZoom(/* factor */) { /* override */ } - - // ================================================================= - // Cleanup - // ================================================================= - - destroy() { - if (this._rafId) { - cancelAnimationFrame(this._rafId); - this._rafId = 0; - } - this._redrawScheduled = false; - - this.canvas.removeEventListener('mousedown', this._onMouseDown); - this.canvas.removeEventListener('mousemove', this._onMouseMove); - this.canvas.removeEventListener('mouseup', this._onMouseUp); - this.canvas.removeEventListener('dblclick', this._onDblClick); - this.canvas.removeEventListener('wheel', this._onWheel); - if (this._attachContextMenu()) { - this.canvas.removeEventListener('auxclick', this._onContextMenu); - this.canvas.removeEventListener('contextmenu', this._onContextMenu); - } - - this.selectedEvents.clear(); - this._undoStack.length = 0; - this._redoStack.length = 0; - this._clipboard.length = 0; - } + // ================================================================= + // Abstract input handlers — subclass MUST implement. + // ================================================================= + + _handleMouseDown(/* e */) { + /* override */ + } + _handleMouseMove(/* e */) { + /* override */ + } + _handleMouseUp(/* e */) { + /* override */ + } + _handleDblClick(/* e */) { + /* override */ + } + _handleWheel(/* e */) { + /* override */ } - if (typeof window !== 'undefined') { - window.CanvasRenderer = CanvasRenderer; + // ================================================================= + // Vertical-zoom hook — subclass implements (row / line / note). + // ================================================================= + + setVerticalZoom(/* factor */) { + /* override */ } - if (typeof module !== 'undefined' && module.exports) { - module.exports = CanvasRenderer; + + // ================================================================= + // Cleanup + // ================================================================= + + destroy() { + if (this._rafId) { + cancelAnimationFrame(this._rafId); + this._rafId = 0; + } + this._redrawScheduled = false; + + this.canvas.removeEventListener('mousedown', this._onMouseDown); + this.canvas.removeEventListener('mousemove', this._onMouseMove); + this.canvas.removeEventListener('mouseup', this._onMouseUp); + this.canvas.removeEventListener('dblclick', this._onDblClick); + this.canvas.removeEventListener('wheel', this._onWheel); + if (this._attachContextMenu()) { + this.canvas.removeEventListener('auxclick', this._onContextMenu); + this.canvas.removeEventListener('contextmenu', this._onContextMenu); + } + + this.selectedEvents.clear(); + this._undoStack.length = 0; + this._redoStack.length = 0; + this._clipboard.length = 0; } + } + + if (typeof window !== 'undefined') { + window.CanvasRenderer = CanvasRenderer; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = CanvasRenderer; + } })(); diff --git a/public/js/features/DrumGridRenderer.js b/public/js/features/DrumGridRenderer.js index edbb5bd25..0f294c8d8 100644 --- a/public/js/features/DrumGridRenderer.js +++ b/public/js/features/DrumGridRenderer.js @@ -8,931 +8,1091 @@ // ============================================================================ class DrumGridRenderer extends CanvasRenderer { - constructor(canvas, options = {}) { - super(canvas, { - headerWidth: 80, - topMargin: 20, - onScrollChange: options.onScrollChange - }); - - // GM drum note names (note 35-81) - this.NOTE_NAMES = { - 35: 'Acoustic Bass Drum', 36: 'Bass Drum 1', 37: 'Side Stick', 38: 'Acoustic Snare', - 39: 'Hand Clap', 40: 'Electric Snare', 41: 'Low Floor Tom', 42: 'Closed Hi-Hat', - 43: 'High Floor Tom', 44: 'Pedal Hi-Hat', 45: 'Low Tom', 46: 'Open Hi-Hat', - 47: 'Low-Mid Tom', 48: 'Hi-Mid Tom', 49: 'Crash Cymbal 1', 50: 'High Tom', - 51: 'Ride Cymbal 1', 52: 'Chinese Cymbal', 53: 'Ride Bell', 54: 'Tambourine', - 55: 'Splash Cymbal', 56: 'Cowbell', 57: 'Crash Cymbal 2', 58: 'Vibraslap', - 59: 'Ride Cymbal 2', 60: 'Hi Bongo', 61: 'Low Bongo', 62: 'Mute Hi Conga', - 63: 'Open Hi Conga', 64: 'Low Conga', 65: 'High Timbale', 66: 'Low Timbale', - 67: 'High Agogo', 68: 'Low Agogo', 69: 'Cabasa', 70: 'Maracas', - 71: 'Short Whistle', 72: 'Long Whistle', 73: 'Short Guiro', 74: 'Long Guiro', - 75: 'Claves', 76: 'Hi Wood Block', 77: 'Low Wood Block', 78: 'Mute Cuica', - 79: 'Open Cuica', 80: 'Mute Triangle', 81: 'Open Triangle' - }; - - // Short labels for compact display - this.SHORT_NAMES = { - 35: 'AcKick', 36: 'Kick', 37: 'Stick', 38: 'Snare', - 39: 'Clap', 40: 'ESnare', 41: 'LFlrTom', 42: 'ClHH', - 43: 'HFlrTom', 44: 'PdlHH', 45: 'LowTom', 46: 'OpHH', - 47: 'LMTom', 48: 'HMTom', 49: 'Crash1', 50: 'HiTom', - 51: 'Ride1', 52: 'China', 53: 'RdBell', 54: 'Tamb', - 55: 'Splash', 56: 'Cowbell', 57: 'Crash2', 58: 'Vibra', - 59: 'Ride2', 60: 'HiBong', 61: 'LoBong', 62: 'MtCnga', - 63: 'OpCnga', 64: 'LoCnga', 65: 'HiTimb', 66: 'LoTimb', - 67: 'HiAgo', 68: 'LoAgo', 69: 'Cabasa', 70: 'Maraca', - 71: 'SWhstl', 72: 'LWhstl', 73: 'SGuiro', 74: 'LGuiro', - 75: 'Claves', 76: 'HiWdBk', 77: 'LoWdBk', 78: 'MtCuic', - 79: 'OpCuic', 80: 'MtTri', 81: 'OpTri' - }; - - // Category colors for drum types - this.CATEGORY_MAP = { - 35: 'kick', 36: 'kick', - 37: 'snare', 38: 'snare', 39: 'snare', 40: 'snare', - 41: 'tom', 43: 'tom', 45: 'tom', 47: 'tom', 48: 'tom', 50: 'tom', - 42: 'hihat', 44: 'hihat', 46: 'hihat', - 49: 'crash', 52: 'crash', 55: 'crash', 57: 'crash', - 51: 'ride', 53: 'ride', 59: 'ride', - 54: 'misc', 56: 'misc', 58: 'misc', 69: 'misc', 70: 'misc', - 60: 'latin', 61: 'latin', 62: 'latin', 63: 'latin', 64: 'latin', - 65: 'latin', 66: 'latin', 67: 'latin', 68: 'latin', - 71: 'misc', 72: 'misc', 73: 'misc', 74: 'misc', 75: 'misc', - 76: 'misc', 77: 'misc', 78: 'misc', 79: 'misc', 80: 'misc', 81: 'misc' - }; - - // Standard display order (most important instruments first) - this.DEFAULT_ROW_ORDER = [ - 49, 57, 55, 52, // Crashes - 51, 59, 53, // Rides - 42, 46, 44, // Hi-hats - 50, 48, 47, 45, // Toms (high to low) - 43, 41, // Floor toms - 38, 40, 37, 39, // Snares - 36, 35, // Kicks - 54, 56, 70, 75, // Misc - 60, 61, 62, 63, 64, // Latin - 65, 66, 67, 68 // More latin - ]; - - // Layout specifics not handled by the base - this.rowHeight = 20; // Pixels per row - this.scrollY = 0; // Vertical scroll in pixels - - // Quantize division (subdivisions per beat): 1=1/4, 2=1/8, 3=1/8T, 4=1/16, 6=1/16T, 8=1/32 - this.quantizeDiv = 4; - - // Grid data: array of { tick, note, velocity, duration, channel, selected } - this.gridEvents = []; - - // Visible rows: only notes that actually appear in the data - this.visibleNotes = []; // Sorted note numbers for rows - - // Playable notes: Set or null (all playable) or undefined (not set) - this.playableNotes = undefined; - // Muted notes (toggled off by user click on label): Set - this.mutedNotes = new Set(); - - // Interaction state specific to this renderer - this._hoverEvent = null; - - // Edit mode: 'pan' (default) or 'select' - this.tool = options.tool || 'pan'; - - // Category color palette (filled by updateTheme()) - this.categoryColors = {}; - this.updateTheme(); + constructor(canvas, options = {}) { + super(canvas, { + headerWidth: 80, + topMargin: 20, + onScrollChange: options.onScrollChange + }); + + // GM drum note names (note 35-81) + this.NOTE_NAMES = { + 35: 'Acoustic Bass Drum', + 36: 'Bass Drum 1', + 37: 'Side Stick', + 38: 'Acoustic Snare', + 39: 'Hand Clap', + 40: 'Electric Snare', + 41: 'Low Floor Tom', + 42: 'Closed Hi-Hat', + 43: 'High Floor Tom', + 44: 'Pedal Hi-Hat', + 45: 'Low Tom', + 46: 'Open Hi-Hat', + 47: 'Low-Mid Tom', + 48: 'Hi-Mid Tom', + 49: 'Crash Cymbal 1', + 50: 'High Tom', + 51: 'Ride Cymbal 1', + 52: 'Chinese Cymbal', + 53: 'Ride Bell', + 54: 'Tambourine', + 55: 'Splash Cymbal', + 56: 'Cowbell', + 57: 'Crash Cymbal 2', + 58: 'Vibraslap', + 59: 'Ride Cymbal 2', + 60: 'Hi Bongo', + 61: 'Low Bongo', + 62: 'Mute Hi Conga', + 63: 'Open Hi Conga', + 64: 'Low Conga', + 65: 'High Timbale', + 66: 'Low Timbale', + 67: 'High Agogo', + 68: 'Low Agogo', + 69: 'Cabasa', + 70: 'Maracas', + 71: 'Short Whistle', + 72: 'Long Whistle', + 73: 'Short Guiro', + 74: 'Long Guiro', + 75: 'Claves', + 76: 'Hi Wood Block', + 77: 'Low Wood Block', + 78: 'Mute Cuica', + 79: 'Open Cuica', + 80: 'Mute Triangle', + 81: 'Open Triangle' + }; + + // Short labels for compact display + this.SHORT_NAMES = { + 35: 'AcKick', + 36: 'Kick', + 37: 'Stick', + 38: 'Snare', + 39: 'Clap', + 40: 'ESnare', + 41: 'LFlrTom', + 42: 'ClHH', + 43: 'HFlrTom', + 44: 'PdlHH', + 45: 'LowTom', + 46: 'OpHH', + 47: 'LMTom', + 48: 'HMTom', + 49: 'Crash1', + 50: 'HiTom', + 51: 'Ride1', + 52: 'China', + 53: 'RdBell', + 54: 'Tamb', + 55: 'Splash', + 56: 'Cowbell', + 57: 'Crash2', + 58: 'Vibra', + 59: 'Ride2', + 60: 'HiBong', + 61: 'LoBong', + 62: 'MtCnga', + 63: 'OpCnga', + 64: 'LoCnga', + 65: 'HiTimb', + 66: 'LoTimb', + 67: 'HiAgo', + 68: 'LoAgo', + 69: 'Cabasa', + 70: 'Maraca', + 71: 'SWhstl', + 72: 'LWhstl', + 73: 'SGuiro', + 74: 'LGuiro', + 75: 'Claves', + 76: 'HiWdBk', + 77: 'LoWdBk', + 78: 'MtCuic', + 79: 'OpCuic', + 80: 'MtTri', + 81: 'OpTri' + }; + + // Category colors for drum types + this.CATEGORY_MAP = { + 35: 'kick', + 36: 'kick', + 37: 'snare', + 38: 'snare', + 39: 'snare', + 40: 'snare', + 41: 'tom', + 43: 'tom', + 45: 'tom', + 47: 'tom', + 48: 'tom', + 50: 'tom', + 42: 'hihat', + 44: 'hihat', + 46: 'hihat', + 49: 'crash', + 52: 'crash', + 55: 'crash', + 57: 'crash', + 51: 'ride', + 53: 'ride', + 59: 'ride', + 54: 'misc', + 56: 'misc', + 58: 'misc', + 69: 'misc', + 70: 'misc', + 60: 'latin', + 61: 'latin', + 62: 'latin', + 63: 'latin', + 64: 'latin', + 65: 'latin', + 66: 'latin', + 67: 'latin', + 68: 'latin', + 71: 'misc', + 72: 'misc', + 73: 'misc', + 74: 'misc', + 75: 'misc', + 76: 'misc', + 77: 'misc', + 78: 'misc', + 79: 'misc', + 80: 'misc', + 81: 'misc' + }; + + // Standard display order (most important instruments first) + this.DEFAULT_ROW_ORDER = [ + 49, + 57, + 55, + 52, // Crashes + 51, + 59, + 53, // Rides + 42, + 46, + 44, // Hi-hats + 50, + 48, + 47, + 45, // Toms (high to low) + 43, + 41, // Floor toms + 38, + 40, + 37, + 39, // Snares + 36, + 35, // Kicks + 54, + 56, + 70, + 75, // Misc + 60, + 61, + 62, + 63, + 64, // Latin + 65, + 66, + 67, + 68 // More latin + ]; + + // Layout specifics not handled by the base + this.rowHeight = 20; // Pixels per row + this.scrollY = 0; // Vertical scroll in pixels + + // Quantize division (subdivisions per beat): 1=1/4, 2=1/8, 3=1/8T, 4=1/16, 6=1/16T, 8=1/32 + this.quantizeDiv = 4; + + // Grid data: array of { tick, note, velocity, duration, channel, selected } + this.gridEvents = []; + + // Visible rows: only notes that actually appear in the data + this.visibleNotes = []; // Sorted note numbers for rows + + // Playable notes: Set or null (all playable) or undefined (not set) + this.playableNotes = undefined; + // Muted notes (toggled off by user click on label): Set + this.mutedNotes = new Set(); + + // Interaction state specific to this renderer + this._hoverEvent = null; + + // Edit mode: 'pan' (default) or 'select' + this.tool = options.tool || 'pan'; + + // Category color palette (filled by updateTheme()) + this.categoryColors = {}; + this.updateTheme(); + } + + /** Drum grid attaches the middle-click guard (legacy behaviour). */ + _attachContextMenu() { + return true; + } + + // ======================================================================== + // THEME + // ======================================================================== + + updateTheme() { + const isDark = document.body.classList.contains('dark-mode'); + if (isDark) { + this.colors = { + background: '#1a1a2e', + rowEven: '#1e2234', + rowOdd: '#1a1a2e', + gridLine: '#2d3748', + measureLine: '#4a5568', + beatLine: '#2d3748', + headerBg: '#2d3748', + headerText: '#a0aec0', + beatNumber: '#718096', + playhead: '#ff4444', + hoverRow: 'rgba(102,126,234,0.12)', + selectedBg: '#667eea', + selectionRect: 'rgba(102,126,234,0.3)' + }; + this.categoryColors = { + kick: '#667eea', + snare: '#ff4444', + hihat: '#28a745', + tom: '#ffc107', + crash: '#17a2b8', + ride: '#6c757d', + latin: '#9b59b6', + misc: '#6c757d' + }; + } else { + this.colors = { + background: '#f0f4ff', + rowEven: '#e8ecff', + rowOdd: '#f0f4ff', + gridLine: '#d4daff', + measureLine: '#b0b8e8', + beatLine: '#d4daff', + headerBg: '#e0e4f8', + headerText: '#5a6089', + beatNumber: '#9498b8', + playhead: '#ef476f', + hoverRow: 'rgba(102,126,234,0.08)', + selectedBg: '#667eea', + selectionRect: 'rgba(102,126,234,0.3)' + }; + this.categoryColors = { + kick: '#667eea', + snare: '#ef476f', + hihat: '#06d6a0', + tom: '#ffd166', + crash: '#118ab2', + ride: '#073b4c', + latin: '#9b59b6', + misc: '#8e99a4' + }; } - - /** Drum grid attaches the middle-click guard (legacy behaviour). */ - _attachContextMenu() { return true; } - - // ======================================================================== - // THEME - // ======================================================================== - - updateTheme() { - const isDark = document.body.classList.contains('dark-mode'); - if (isDark) { - this.colors = { - background: '#1a1a2e', - rowEven: '#1e2234', - rowOdd: '#1a1a2e', - gridLine: '#2d3748', - measureLine: '#4a5568', - beatLine: '#2d3748', - headerBg: '#2d3748', - headerText: '#a0aec0', - beatNumber: '#718096', - playhead: '#ff4444', - hoverRow: 'rgba(102,126,234,0.12)', - selectedBg: '#667eea', - selectionRect: 'rgba(102,126,234,0.3)', - }; - this.categoryColors = { - kick: '#667eea', snare: '#ff4444', hihat: '#28a745', - tom: '#ffc107', crash: '#17a2b8', ride: '#6c757d', - latin: '#9b59b6', misc: '#6c757d' - }; - } else { - this.colors = { - background: '#f0f4ff', - rowEven: '#e8ecff', - rowOdd: '#f0f4ff', - gridLine: '#d4daff', - measureLine: '#b0b8e8', - beatLine: '#d4daff', - headerBg: '#e0e4f8', - headerText: '#5a6089', - beatNumber: '#9498b8', - playhead: '#ef476f', - hoverRow: 'rgba(102,126,234,0.08)', - selectedBg: '#667eea', - selectionRect: 'rgba(102,126,234,0.3)', - }; - this.categoryColors = { - kick: '#667eea', snare: '#ef476f', hihat: '#06d6a0', - tom: '#ffd166', crash: '#118ab2', ride: '#073b4c', - latin: '#9b59b6', misc: '#8e99a4' - }; - } + } + + // ======================================================================== + // DATA + // ======================================================================== + + setGridEvents(events) { + this.gridEvents = events || []; + this._updateVisibleNotes(); + this.requestRedraw(); + } + + _updateVisibleNotes() { + // Determine which notes are actually used + const usedNotes = new Set(); + for (const evt of this.gridEvents) { + usedNotes.add(evt.note); } - // ======================================================================== - // DATA - // ======================================================================== - - setGridEvents(events) { - this.gridEvents = events || []; - this._updateVisibleNotes(); - this.requestRedraw(); + // Use DEFAULT_ROW_ORDER for sorting, then add any notes not in the default order + this.visibleNotes = this.DEFAULT_ROW_ORDER.filter((n) => usedNotes.has(n)); + for (const n of usedNotes) { + if (!this.visibleNotes.includes(n)) { + this.visibleNotes.push(n); + } } - - _updateVisibleNotes() { - // Determine which notes are actually used - const usedNotes = new Set(); - for (const evt of this.gridEvents) { - usedNotes.add(evt.note); - } - - // Use DEFAULT_ROW_ORDER for sorting, then add any notes not in the default order - this.visibleNotes = this.DEFAULT_ROW_ORDER.filter(n => usedNotes.has(n)); - for (const n of usedNotes) { - if (!this.visibleNotes.includes(n)) { - this.visibleNotes.push(n); - } - } + } + + // setScrollX / setZoom / setPlayhead / setTimeSignature / _notifyScrollChange + // are provided by CanvasRenderer. + + setScrollY(pixelOffset) { + this.scrollY = Math.max(0, pixelOffset); + this.requestRedraw(); + } + + /** + * Vertical zoom: adjust row height. + * factor < 1 = zoom in (taller rows), factor > 1 = zoom out (shorter rows) + */ + setVerticalZoom(factor) { + this.rowHeight = Math.max(12, Math.min(40, Math.round(this.rowHeight / factor))); + this.requestRedraw(); + } + + // ======================================================================== + // SELECTION + // ======================================================================== + + selectEvent(index) { + this.selectedEvents.add(index); + this.requestRedraw(); + } + deselectEvent(index) { + this.selectedEvents.delete(index); + this.requestRedraw(); + } + clearSelection() { + this.selectedEvents.clear(); + this.requestRedraw(); + } + + selectAll() { + for (let i = 0; i < this.gridEvents.length; i++) this.selectedEvents.add(i); + this.requestRedraw(); + } + + getSelectedEvents() { + return Array.from(this.selectedEvents) + .map((i) => this.gridEvents[i]) + .filter(Boolean); + } + + getSelectedIndices() { + return Array.from(this.selectedEvents); + } + + deleteSelected() { + if (this.selectedEvents.size === 0) return 0; + this.saveSnapshot(); + const indices = Array.from(this.selectedEvents).sort((a, b) => b - a); + for (const i of indices) this.gridEvents.splice(i, 1); + this.selectedEvents.clear(); + this._updateVisibleNotes(); + this.requestRedraw(); + return indices.length; + } + + // ======================================================================== + // UNDO / REDO + // ======================================================================== + + saveSnapshot() { + this._undoStack.push(this.gridEvents.map((e) => ({ ...e }))); + this._redoStack = []; + if (this._undoStack.length > this._maxUndoSize) this._undoStack.shift(); + } + + undo() { + if (this._undoStack.length === 0) return false; + this._redoStack.push(this.gridEvents.map((e) => ({ ...e }))); + this.gridEvents = this._undoStack.pop().map((e) => ({ ...e })); + this.selectedEvents.clear(); + this._updateVisibleNotes(); + this.requestRedraw(); + return true; + } + + redo() { + if (this._redoStack.length === 0) return false; + this._undoStack.push(this.gridEvents.map((e) => ({ ...e }))); + this.gridEvents = this._redoStack.pop().map((e) => ({ ...e })); + this.selectedEvents.clear(); + this._updateVisibleNotes(); + this.requestRedraw(); + return true; + } + + canUndo() { + return this._undoStack.length > 0; + } + canRedo() { + return this._redoStack.length > 0; + } + + // ======================================================================== + // CLIPBOARD + // ======================================================================== + + copySelected() { + if (this.selectedEvents.size === 0) return 0; + const selected = this.getSelectedEvents(); + if (selected.length === 0) return 0; + const minTick = Math.min(...selected.map((e) => e.tick)); + this._clipboard = selected.map((e) => ({ ...e, tick: e.tick - minTick })); + return this._clipboard.length; + } + + paste(atTick) { + if (this._clipboard.length === 0) return 0; + this.saveSnapshot(); + this.selectedEvents.clear(); + for (const evt of this._clipboard) { + this.gridEvents.push({ ...evt, tick: evt.tick + atTick }); } - - // setScrollX / setZoom / setPlayhead / setTimeSignature / _notifyScrollChange - // are provided by CanvasRenderer. - - setScrollY(pixelOffset) { - this.scrollY = Math.max(0, pixelOffset); - this.requestRedraw(); + this.gridEvents.sort((a, b) => a.tick - b.tick); + this._updateVisibleNotes(); + this.requestRedraw(); + return this._clipboard.length; + } + + hasClipboard() { + return this._clipboard.length > 0; + } + + // ======================================================================== + // RENDERING + // ======================================================================== + + // requestRedraw() is provided by CanvasRenderer. + + redraw() { + const { canvas, ctx } = this; + const w = canvas.width; + const h = canvas.height; + + ctx.fillStyle = this.colors.background; + ctx.fillRect(0, 0, w, h); + + this._drawRowBackgrounds(w, h); + this._drawGrid(w, h); + this._drawHits(w, h); + + if (this.selectionRect) this._drawSelectionRect(); + + this._drawPlayhead(w, h); + this._drawHeader(w); + this._drawRowLabels(h); + this._drawScrollbar(w, h); + } + + resize(width, height) { + this.canvas.width = width; + this.canvas.height = height; + this.requestRedraw(); + } + + // ======================================================================== + // DRAWING + // ======================================================================== + + _drawRowBackgrounds(w, h) { + const ctx = this.ctx; + for (let i = 0; i < this.visibleNotes.length; i++) { + const note = this.visibleNotes[i]; + const y = this._rowToY(i); + if (y + this.rowHeight < 0 || y > h) continue; + + ctx.fillStyle = i % 2 === 0 ? this.colors.rowEven : this.colors.rowOdd; + ctx.fillRect(this.headerWidth, y, w - this.headerWidth, this.rowHeight); + + // Dim muted rows + if (this.mutedNotes.has(note)) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; + ctx.fillRect(this.headerWidth, y, w - this.headerWidth, this.rowHeight); + } } - - /** - * Vertical zoom: adjust row height. - * factor < 1 = zoom in (taller rows), factor > 1 = zoom out (shorter rows) - */ - setVerticalZoom(factor) { - this.rowHeight = Math.max(12, Math.min(40, Math.round(this.rowHeight / factor))); - this.requestRedraw(); + } + + _drawGrid(w, h) { + const ctx = this.ctx; + const ticksPerMeasure = this.ticksPerBeat * this.beatsPerMeasure; + const startTick = this.scrollX; + const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; + + // Subdivision lines (based on quantize division) + const ticksPerDiv = this.ticksPerBeat / this.quantizeDiv; + const firstDiv = Math.floor(startTick / ticksPerDiv) * ticksPerDiv; + ctx.strokeStyle = this.colors.gridLine; + ctx.lineWidth = 0.5; + ctx.globalAlpha = 0.4; + for (let tick = firstDiv; tick <= endTick; tick += ticksPerDiv) { + const x = this._tickToX(tick); + if (x < this.headerWidth) continue; + ctx.beginPath(); + ctx.moveTo(x, this.topMargin); + ctx.lineTo(x, h); + ctx.stroke(); } - - // ======================================================================== - // SELECTION - // ======================================================================== - - selectEvent(index) { this.selectedEvents.add(index); this.requestRedraw(); } - deselectEvent(index) { this.selectedEvents.delete(index); this.requestRedraw(); } - clearSelection() { this.selectedEvents.clear(); this.requestRedraw(); } - - selectAll() { - for (let i = 0; i < this.gridEvents.length; i++) this.selectedEvents.add(i); - this.requestRedraw(); - } - - getSelectedEvents() { - return Array.from(this.selectedEvents).map(i => this.gridEvents[i]).filter(Boolean); - } - - getSelectedIndices() { - return Array.from(this.selectedEvents); - } - - deleteSelected() { - if (this.selectedEvents.size === 0) return 0; - this.saveSnapshot(); - const indices = Array.from(this.selectedEvents).sort((a, b) => b - a); - for (const i of indices) this.gridEvents.splice(i, 1); - this.selectedEvents.clear(); - this._updateVisibleNotes(); - this.requestRedraw(); - return indices.length; - } - - // ======================================================================== - // UNDO / REDO - // ======================================================================== - - saveSnapshot() { - this._undoStack.push(this.gridEvents.map(e => ({ ...e }))); - this._redoStack = []; - if (this._undoStack.length > this._maxUndoSize) this._undoStack.shift(); - } - - undo() { - if (this._undoStack.length === 0) return false; - this._redoStack.push(this.gridEvents.map(e => ({ ...e }))); - this.gridEvents = this._undoStack.pop().map(e => ({ ...e })); - this.selectedEvents.clear(); - this._updateVisibleNotes(); - this.requestRedraw(); - return true; + ctx.globalAlpha = 1.0; + + // Beat lines + const firstBeat = Math.floor(startTick / this.ticksPerBeat) * this.ticksPerBeat; + ctx.strokeStyle = this.colors.beatLine; + ctx.lineWidth = 0.5; + for (let tick = firstBeat; tick <= endTick; tick += this.ticksPerBeat) { + const x = this._tickToX(tick); + if (x < this.headerWidth) continue; + ctx.beginPath(); + ctx.moveTo(x, this.topMargin); + ctx.lineTo(x, h); + ctx.stroke(); } - redo() { - if (this._redoStack.length === 0) return false; - this._undoStack.push(this.gridEvents.map(e => ({ ...e }))); - this.gridEvents = this._redoStack.pop().map(e => ({ ...e })); - this.selectedEvents.clear(); - this._updateVisibleNotes(); - this.requestRedraw(); - return true; + // Measure lines + const firstMeasure = Math.floor(startTick / ticksPerMeasure) * ticksPerMeasure; + ctx.strokeStyle = this.colors.measureLine; + ctx.lineWidth = 1; + for (let tick = firstMeasure; tick <= endTick; tick += ticksPerMeasure) { + const x = this._tickToX(tick); + if (x < this.headerWidth) continue; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); } - canUndo() { return this._undoStack.length > 0; } - canRedo() { return this._redoStack.length > 0; } - - // ======================================================================== - // CLIPBOARD - // ======================================================================== - - copySelected() { - if (this.selectedEvents.size === 0) return 0; - const selected = this.getSelectedEvents(); - if (selected.length === 0) return 0; - const minTick = Math.min(...selected.map(e => e.tick)); - this._clipboard = selected.map(e => ({ ...e, tick: e.tick - minTick })); - return this._clipboard.length; + // Row divider lines + ctx.strokeStyle = this.colors.gridLine; + ctx.lineWidth = 0.5; + for (let i = 0; i <= this.visibleNotes.length; i++) { + const y = this._rowToY(i); + ctx.beginPath(); + ctx.moveTo(this.headerWidth, y); + ctx.lineTo(w, y); + ctx.stroke(); } - - paste(atTick) { - if (this._clipboard.length === 0) return 0; - this.saveSnapshot(); - this.selectedEvents.clear(); - for (const evt of this._clipboard) { - this.gridEvents.push({ ...evt, tick: evt.tick + atTick }); + } + + _drawHits(w, _h) { + const ctx = this.ctx; + const startTick = this.scrollX; + const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; + + for (let i = 0; i < this.gridEvents.length; i++) { + const evt = this.gridEvents[i]; + if (evt.tick < startTick - 100 || evt.tick > endTick) continue; + + const rowIndex = this.visibleNotes.indexOf(evt.note); + if (rowIndex < 0) continue; + + const x = this._tickToX(evt.tick); + if (x < this.headerWidth - 5) continue; + + const y = this._rowToY(rowIndex); + const isSelected = this.selectedEvents.has(i); + + // Hit cell + const cellW = Math.max(6, Math.min(16, this.rowHeight - 4)); + const cellH = this.rowHeight - 4; + const cx = x - cellW / 2; + const cy = y + 2; + + // Velocity-based opacity + const velocity = evt.velocity || 100; + const isMuted = this.mutedNotes.has(evt.note); + const alpha = isMuted ? 0.15 : 0.3 + (velocity / 127) * 0.7; + + const category = this.CATEGORY_MAP[evt.note] || 'misc'; + const color = this.categoryColors[category] || this.categoryColors.misc; + + if (isSelected && !isMuted) { + ctx.fillStyle = this.colors.selectedBg; + ctx.globalAlpha = 1; + } else { + ctx.fillStyle = isMuted ? '#555' : color; + ctx.globalAlpha = alpha; + } + + ctx.beginPath(); + ctx.roundRect(cx, cy, cellW, cellH, 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + + // Mini velocity bar at bottom of cell (secondary visual cue) + const velRatio = velocity / 127; + ctx.fillStyle = isSelected ? '#ffffff' : color; + ctx.globalAlpha = 0.9; + ctx.fillRect(cx, cy + cellH - 2, cellW * velRatio, 2); + ctx.globalAlpha = 1.0; + + // Duration line if present + if (evt.duration && evt.duration > 0) { + const endX = this._tickToX(evt.tick + evt.duration); + if (endX > x + cellW / 2) { + ctx.strokeStyle = isSelected ? this.colors.selectedBg : color; + ctx.lineWidth = 2; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(cx + cellW, y + this.rowHeight / 2); + ctx.lineTo(Math.min(endX, w), y + this.rowHeight / 2); + ctx.stroke(); + ctx.globalAlpha = 1.0; } - this.gridEvents.sort((a, b) => a.tick - b.tick); - this._updateVisibleNotes(); - this.requestRedraw(); - return this._clipboard.length; + } } - - hasClipboard() { return this._clipboard.length > 0; } - - // ======================================================================== - // RENDERING - // ======================================================================== - - // requestRedraw() is provided by CanvasRenderer. - - redraw() { - const { canvas, ctx } = this; - const w = canvas.width; - const h = canvas.height; - - ctx.fillStyle = this.colors.background; - ctx.fillRect(0, 0, w, h); - - this._drawRowBackgrounds(w, h); - this._drawGrid(w, h); - this._drawHits(w, h); - - if (this.selectionRect) this._drawSelectionRect(); - - this._drawPlayhead(w, h); - this._drawHeader(w); - this._drawRowLabels(h); - this._drawScrollbar(w, h); + } + + _drawPlayhead(w, h) { + if (this.playheadTick < this.scrollX) return; + const x = this._tickToX(this.playheadTick); + if (x < this.headerWidth || x > w) return; + + const ctx = this.ctx; + ctx.strokeStyle = this.colors.playhead; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + + // Triangle + ctx.fillStyle = this.colors.playhead; + ctx.beginPath(); + ctx.moveTo(x - 5, 0); + ctx.lineTo(x + 5, 0); + ctx.lineTo(x, 7); + ctx.closePath(); + ctx.fill(); + } + + _drawHeader(w) { + const ctx = this.ctx; + // Background for beat number row + ctx.fillStyle = this.colors.headerBg; + ctx.fillRect(0, 0, w, this.topMargin); + + // Measure/beat numbers + const ticksPerMeasure = this.ticksPerBeat * this.beatsPerMeasure; + const startTick = this.scrollX; + const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; + + const firstMeasure = Math.floor(startTick / ticksPerMeasure) * ticksPerMeasure; + ctx.fillStyle = this.colors.beatNumber; + ctx.font = '9px monospace'; + ctx.textAlign = 'left'; + for (let tick = firstMeasure; tick <= endTick; tick += ticksPerMeasure) { + const x = this._tickToX(tick); + if (x < this.headerWidth) continue; + const measureNum = Math.round(tick / ticksPerMeasure) + 1; + ctx.fillText(measureNum.toString(), x + 2, this.topMargin - 5); } + } - resize(width, height) { - this.canvas.width = width; - this.canvas.height = height; - this.requestRedraw(); + _getNoteName(note) { + if (typeof i18n !== 'undefined') { + const translated = i18n.t('drumNotes.' + note); + if (translated !== 'drumNotes.' + note) return translated; } + return this.NOTE_NAMES[note] || `Note ${note}`; + } - // ======================================================================== - // DRAWING - // ======================================================================== - - _drawRowBackgrounds(w, h) { - const ctx = this.ctx; - for (let i = 0; i < this.visibleNotes.length; i++) { - const note = this.visibleNotes[i]; - const y = this._rowToY(i); - if (y + this.rowHeight < 0 || y > h) continue; - - ctx.fillStyle = i % 2 === 0 ? this.colors.rowEven : this.colors.rowOdd; - ctx.fillRect(this.headerWidth, y, w - this.headerWidth, this.rowHeight); - - // Dim muted rows - if (this.mutedNotes.has(note)) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; - ctx.fillRect(this.headerWidth, y, w - this.headerWidth, this.rowHeight); - } - } + _getShortName(note) { + if (typeof i18n !== 'undefined') { + const translated = i18n.t('drumNotes.short.' + note); + if (translated !== 'drumNotes.short.' + note) return translated; } - - _drawGrid(w, h) { - const ctx = this.ctx; - const ticksPerMeasure = this.ticksPerBeat * this.beatsPerMeasure; - const startTick = this.scrollX; - const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; - - // Subdivision lines (based on quantize division) - const ticksPerDiv = this.ticksPerBeat / this.quantizeDiv; - const firstDiv = Math.floor(startTick / ticksPerDiv) * ticksPerDiv; - ctx.strokeStyle = this.colors.gridLine; - ctx.lineWidth = 0.5; - ctx.globalAlpha = 0.4; - for (let tick = firstDiv; tick <= endTick; tick += ticksPerDiv) { - const x = this._tickToX(tick); - if (x < this.headerWidth) continue; - ctx.beginPath(); - ctx.moveTo(x, this.topMargin); - ctx.lineTo(x, h); - ctx.stroke(); + return this.SHORT_NAMES[note] || `${note}`; + } + + _drawRowLabels(h) { + const ctx = this.ctx; + // Header background + ctx.fillStyle = this.colors.headerBg; + ctx.fillRect(0, this.topMargin, this.headerWidth, h - this.topMargin); + + // Border + ctx.strokeStyle = this.colors.measureLine; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(this.headerWidth, 0); + ctx.lineTo(this.headerWidth, h); + ctx.stroke(); + + const hasPlayableInfo = this.playableNotes !== undefined; + + ctx.font = 'bold 9px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + for (let i = 0; i < this.visibleNotes.length; i++) { + const note = this.visibleNotes[i]; + const y = this._rowToY(i); + const cy = y + this.rowHeight / 2; + + if (cy < this.topMargin || cy > h) continue; + + const isMuted = this.mutedNotes.has(note); + + // Playable note background (only when routing info available) + if (hasPlayableInfo && !isMuted) { + const isPlayable = this.playableNotes === null || this.playableNotes.has(note); + if (isPlayable) { + ctx.fillStyle = 'rgba(0, 200, 80, 0.25)'; + ctx.fillRect(0, y, this.headerWidth, this.rowHeight); } - ctx.globalAlpha = 1.0; - - // Beat lines - const firstBeat = Math.floor(startTick / this.ticksPerBeat) * this.ticksPerBeat; - ctx.strokeStyle = this.colors.beatLine; - ctx.lineWidth = 0.5; - for (let tick = firstBeat; tick <= endTick; tick += this.ticksPerBeat) { - const x = this._tickToX(tick); - if (x < this.headerWidth) continue; - ctx.beginPath(); - ctx.moveTo(x, this.topMargin); - ctx.lineTo(x, h); - ctx.stroke(); - } - - // Measure lines - const firstMeasure = Math.floor(startTick / ticksPerMeasure) * ticksPerMeasure; - ctx.strokeStyle = this.colors.measureLine; - ctx.lineWidth = 1; - for (let tick = firstMeasure; tick <= endTick; tick += ticksPerMeasure) { - const x = this._tickToX(tick); - if (x < this.headerWidth) continue; - ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, h); - ctx.stroke(); - } - - // Row divider lines - ctx.strokeStyle = this.colors.gridLine; - ctx.lineWidth = 0.5; - for (let i = 0; i <= this.visibleNotes.length; i++) { - const y = this._rowToY(i); - ctx.beginPath(); - ctx.moveTo(this.headerWidth, y); - ctx.lineTo(w, y); - ctx.stroke(); - } - } - - _drawHits(w, _h) { - const ctx = this.ctx; - const startTick = this.scrollX; - const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; - - for (let i = 0; i < this.gridEvents.length; i++) { - const evt = this.gridEvents[i]; - if (evt.tick < startTick - 100 || evt.tick > endTick) continue; - - const rowIndex = this.visibleNotes.indexOf(evt.note); - if (rowIndex < 0) continue; - - const x = this._tickToX(evt.tick); - if (x < this.headerWidth - 5) continue; - - const y = this._rowToY(rowIndex); - const isSelected = this.selectedEvents.has(i); - - // Hit cell - const cellW = Math.max(6, Math.min(16, this.rowHeight - 4)); - const cellH = this.rowHeight - 4; - const cx = x - cellW / 2; - const cy = y + 2; - - // Velocity-based opacity - const velocity = evt.velocity || 100; - const isMuted = this.mutedNotes.has(evt.note); - const alpha = isMuted ? 0.15 : (0.3 + (velocity / 127) * 0.7); - - const category = this.CATEGORY_MAP[evt.note] || 'misc'; - const color = this.categoryColors[category] || this.categoryColors.misc; - - if (isSelected && !isMuted) { - ctx.fillStyle = this.colors.selectedBg; - ctx.globalAlpha = 1; - } else { - ctx.fillStyle = isMuted ? '#555' : color; - ctx.globalAlpha = alpha; - } - - ctx.beginPath(); - ctx.roundRect(cx, cy, cellW, cellH, 2); - ctx.fill(); - ctx.globalAlpha = 1.0; - - // Mini velocity bar at bottom of cell (secondary visual cue) - const velRatio = velocity / 127; - ctx.fillStyle = isSelected ? '#ffffff' : color; - ctx.globalAlpha = 0.9; - ctx.fillRect(cx, cy + cellH - 2, cellW * velRatio, 2); - ctx.globalAlpha = 1.0; - - // Duration line if present - if (evt.duration && evt.duration > 0) { - const endX = this._tickToX(evt.tick + evt.duration); - if (endX > x + cellW / 2) { - ctx.strokeStyle = isSelected ? this.colors.selectedBg : color; - ctx.lineWidth = 2; - ctx.globalAlpha = 0.4; - ctx.beginPath(); - ctx.moveTo(cx + cellW, y + this.rowHeight / 2); - ctx.lineTo(Math.min(endX, w), y + this.rowHeight / 2); - ctx.stroke(); - ctx.globalAlpha = 1.0; - } - } - } - } - - _drawPlayhead(w, h) { - if (this.playheadTick < this.scrollX) return; - const x = this._tickToX(this.playheadTick); - if (x < this.headerWidth || x > w) return; - - const ctx = this.ctx; - ctx.strokeStyle = this.colors.playhead; - ctx.lineWidth = 2; + } + + // Muted row: grey overlay on label area + if (isMuted) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; + ctx.fillRect(0, y, this.headerWidth, this.rowHeight); + } + + // Mute toggle indicator (wider zone at left) + const muteW = 16; + if (isMuted) { + ctx.fillStyle = 'rgba(255, 60, 60, 0.3)'; + ctx.fillRect(0, y, muteW, this.rowHeight); + // Cross icon + ctx.strokeStyle = '#ff4444'; + ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.moveTo(x, 0); - ctx.lineTo(x, h); + ctx.moveTo(4, cy - 4); + ctx.lineTo(12, cy + 4); + ctx.moveTo(12, cy - 4); + ctx.lineTo(4, cy + 4); ctx.stroke(); - - // Triangle - ctx.fillStyle = this.colors.playhead; + } else { + ctx.fillStyle = 'rgba(0, 200, 80, 0.15)'; + ctx.fillRect(0, y, muteW, this.rowHeight); + // Small filled circle ctx.beginPath(); - ctx.moveTo(x - 5, 0); - ctx.lineTo(x + 5, 0); - ctx.lineTo(x, 7); - ctx.closePath(); + ctx.arc(8, cy, 3, 0, Math.PI * 2); + ctx.fillStyle = '#00c850'; ctx.fill(); + } + + // Separator line after mute zone + ctx.strokeStyle = this.colors.measureLine; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(muteW, y); + ctx.lineTo(muteW, y + this.rowHeight); + ctx.stroke(); + + // Category color indicator + const category = this.CATEGORY_MAP[note] || 'misc'; + const color = this.categoryColors[category] || this.categoryColors.misc; + + ctx.fillStyle = color; + ctx.fillRect(18, y + 2, 4, this.rowHeight - 4); + + // Label text (clickable for play) + if (isMuted) { + ctx.fillStyle = '#555'; + } else if (hasPlayableInfo && (this.playableNotes === null || this.playableNotes.has(note))) { + ctx.fillStyle = '#00e050'; + } else { + ctx.fillStyle = this.colors.headerText; + } + const label = this._getShortName(note); + ctx.fillText(label, this.headerWidth - 6, cy); } - _drawHeader(w) { - const ctx = this.ctx; - // Background for beat number row - ctx.fillStyle = this.colors.headerBg; - ctx.fillRect(0, 0, w, this.topMargin); - - // Measure/beat numbers - const ticksPerMeasure = this.ticksPerBeat * this.beatsPerMeasure; - const startTick = this.scrollX; - const endTick = startTick + (w - this.headerWidth) * this.ticksPerPixel; - - const firstMeasure = Math.floor(startTick / ticksPerMeasure) * ticksPerMeasure; - ctx.fillStyle = this.colors.beatNumber; - ctx.font = '9px monospace'; - ctx.textAlign = 'left'; - for (let tick = firstMeasure; tick <= endTick; tick += ticksPerMeasure) { - const x = this._tickToX(tick); - if (x < this.headerWidth) continue; - const measureNum = Math.round(tick / ticksPerMeasure) + 1; - ctx.fillText(measureNum.toString(), x + 2, this.topMargin - 5); - } + ctx.textAlign = 'left'; // Reset + } + + _drawSelectionRect() { + const ctx = this.ctx; + const r = this.selectionRect; + const x = Math.min(r.x1, r.x2); + const y = Math.min(r.y1, r.y2); + const w = Math.abs(r.x2 - r.x1); + const h = Math.abs(r.y2 - r.y1); + + ctx.fillStyle = this.colors.selectionRect; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = this.colors.selectedBg; + ctx.lineWidth = 1; + ctx.strokeRect(x, y, w, h); + } + + // ======================================================================== + // COORDINATE CONVERSION + // ======================================================================== + + // _tickToX / _xToTick are inherited from CanvasRenderer. + + _rowToY(rowIndex) { + return this.topMargin + rowIndex * this.rowHeight - this.scrollY; + } + + _yToRow(y) { + return Math.floor((y - this.topMargin + this.scrollY) / this.rowHeight); + } + + _yToNote(y) { + const rowIndex = this._yToRow(y); + if (rowIndex < 0 || rowIndex >= this.visibleNotes.length) return -1; + return this.visibleNotes[rowIndex]; + } + + getRequiredHeight() { + return this.topMargin + this.visibleNotes.length * this.rowHeight + 10; + } + + getMaxTick() { + if (this.gridEvents.length === 0) return 0; + return Math.max(...this.gridEvents.map((e) => e.tick + (e.duration || 0))); + } + + // ======================================================================== + // HIT TESTING + // ======================================================================== + + _hitTest(canvasX, canvasY) { + const tick = this._xToTick(canvasX); + const note = this._yToNote(canvasY); + if (note < 0) return -1; + + const hitRadius = 8 * this.ticksPerPixel; + for (let i = 0; i < this.gridEvents.length; i++) { + const evt = this.gridEvents[i]; + if (evt.note === note && Math.abs(evt.tick - tick) < hitRadius) { + return i; + } } - - _getNoteName(note) { - if (typeof i18n !== 'undefined') { - const translated = i18n.t('drumNotes.' + note); - if (translated !== 'drumNotes.' + note) return translated; + return -1; + } + + // ======================================================================== + // MOUSE INTERACTION + // ======================================================================== + + _handleMouseDown(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Click on row label area + if (x < this.headerWidth && y > this.topMargin) { + const rowIndex = this._yToRow(y); + if (rowIndex >= 0 && rowIndex < this.visibleNotes.length) { + const note = this.visibleNotes[rowIndex]; + if (x <= 16) { + // Mute toggle zone (left side) + if (this.mutedNotes.has(note)) { + this.mutedNotes.delete(note); + } else { + this.mutedNotes.add(note); + } + this.requestRedraw(); + this._emitEvent('labelclick', { note, muted: this.mutedNotes.has(note) }); + } else { + // Label area: play the drum sound + this._emitEvent('playrow', { note }); } - return this.NOTE_NAMES[note] || `Note ${note}`; + return; + } } - _getShortName(note) { - if (typeof i18n !== 'undefined') { - const translated = i18n.t('drumNotes.short.' + note); - if (translated !== 'drumNotes.short.' + note) return translated; - } - return this.SHORT_NAMES[note] || `${note}`; + // Pan mode: pan by default, select with shift + // Select mode: select by default, pan with alt/middle + const forcePan = e.altKey || e.button === 1; + const usePan = forcePan || (this.tool === 'pan' && !e.shiftKey); + + if (usePan) { + this._isDragging = true; + this._dragMode = 'pan'; + this._dragStart = { x, y, scrollX: this.scrollX, scrollY: this.scrollY }; + this.canvas.style.cursor = 'grabbing'; + e.preventDefault(); + return; } - _drawRowLabels(h) { - const ctx = this.ctx; - // Header background - ctx.fillStyle = this.colors.headerBg; - ctx.fillRect(0, this.topMargin, this.headerWidth, h - this.topMargin); - - // Border - ctx.strokeStyle = this.colors.measureLine; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(this.headerWidth, 0); - ctx.lineTo(this.headerWidth, h); - ctx.stroke(); + const hitIndex = this._hitTest(x, y); - const hasPlayableInfo = this.playableNotes !== undefined; - - ctx.font = 'bold 9px monospace'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - - for (let i = 0; i < this.visibleNotes.length; i++) { - const note = this.visibleNotes[i]; - const y = this._rowToY(i); - const cy = y + this.rowHeight / 2; - - if (cy < this.topMargin || cy > h) continue; - - const isMuted = this.mutedNotes.has(note); - - // Playable note background (only when routing info available) - if (hasPlayableInfo && !isMuted) { - const isPlayable = this.playableNotes === null || this.playableNotes.has(note); - if (isPlayable) { - ctx.fillStyle = 'rgba(0, 200, 80, 0.25)'; - ctx.fillRect(0, y, this.headerWidth, this.rowHeight); - } - } - - // Muted row: grey overlay on label area - if (isMuted) { - ctx.fillStyle = 'rgba(0, 0, 0, 0.25)'; - ctx.fillRect(0, y, this.headerWidth, this.rowHeight); - } - - // Mute toggle indicator (wider zone at left) - const muteW = 16; - if (isMuted) { - ctx.fillStyle = 'rgba(255, 60, 60, 0.3)'; - ctx.fillRect(0, y, muteW, this.rowHeight); - // Cross icon - ctx.strokeStyle = '#ff4444'; - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(4, cy - 4); - ctx.lineTo(12, cy + 4); - ctx.moveTo(12, cy - 4); - ctx.lineTo(4, cy + 4); - ctx.stroke(); - } else { - ctx.fillStyle = 'rgba(0, 200, 80, 0.15)'; - ctx.fillRect(0, y, muteW, this.rowHeight); - // Small filled circle - ctx.beginPath(); - ctx.arc(8, cy, 3, 0, Math.PI * 2); - ctx.fillStyle = '#00c850'; - ctx.fill(); - } - - // Separator line after mute zone - ctx.strokeStyle = this.colors.measureLine; - ctx.lineWidth = 0.5; - ctx.beginPath(); - ctx.moveTo(muteW, y); - ctx.lineTo(muteW, y + this.rowHeight); - ctx.stroke(); - - // Category color indicator - const category = this.CATEGORY_MAP[note] || 'misc'; - const color = this.categoryColors[category] || this.categoryColors.misc; - - ctx.fillStyle = color; - ctx.fillRect(18, y + 2, 4, this.rowHeight - 4); - - // Label text (clickable for play) - if (isMuted) { - ctx.fillStyle = '#555'; - } else if (hasPlayableInfo && (this.playableNotes === null || this.playableNotes.has(note))) { - ctx.fillStyle = '#00e050'; - } else { - ctx.fillStyle = this.colors.headerText; - } - const label = this._getShortName(note); - ctx.fillText(label, this.headerWidth - 6, cy); + if (hitIndex >= 0) { + if (e.ctrlKey || e.metaKey) { + if (this.selectedEvents.has(hitIndex)) { + this.selectedEvents.delete(hitIndex); + } else { + this.selectedEvents.add(hitIndex); } - - ctx.textAlign = 'left'; // Reset - } - - _drawSelectionRect() { - const ctx = this.ctx; - const r = this.selectionRect; - const x = Math.min(r.x1, r.x2); - const y = Math.min(r.y1, r.y2); - const w = Math.abs(r.x2 - r.x1); - const h = Math.abs(r.y2 - r.y1); - - ctx.fillStyle = this.colors.selectionRect; - ctx.fillRect(x, y, w, h); - ctx.strokeStyle = this.colors.selectedBg; - ctx.lineWidth = 1; - ctx.strokeRect(x, y, w, h); - } - - // ======================================================================== - // COORDINATE CONVERSION - // ======================================================================== - - // _tickToX / _xToTick are inherited from CanvasRenderer. - - _rowToY(rowIndex) { - return this.topMargin + rowIndex * this.rowHeight - this.scrollY; - } - - _yToRow(y) { - return Math.floor((y - this.topMargin + this.scrollY) / this.rowHeight); - } - - _yToNote(y) { - const rowIndex = this._yToRow(y); - if (rowIndex < 0 || rowIndex >= this.visibleNotes.length) return -1; - return this.visibleNotes[rowIndex]; - } - - getRequiredHeight() { - return this.topMargin + this.visibleNotes.length * this.rowHeight + 10; + } else if (!this.selectedEvents.has(hitIndex)) { + this.selectedEvents.clear(); + this.selectedEvents.add(hitIndex); + } + this.requestRedraw(); + this._emitEvent('selectionchange', { selected: this.getSelectedIndices() }); + } else { + if (!e.ctrlKey && !e.metaKey) this.selectedEvents.clear(); + this._isDragging = true; + this._dragMode = 'select'; + this._dragStart = { x, y }; + this.selectionRect = { x1: x, y1: y, x2: x, y2: y }; + this.requestRedraw(); } + } - getMaxTick() { - if (this.gridEvents.length === 0) return 0; - return Math.max(...this.gridEvents.map(e => e.tick + (e.duration || 0))); - } + _handleMouseMove(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; - // ======================================================================== - // HIT TESTING - // ======================================================================== - - _hitTest(canvasX, canvasY) { - const tick = this._xToTick(canvasX); - const note = this._yToNote(canvasY); - if (note < 0) return -1; - - const hitRadius = 8 * this.ticksPerPixel; - for (let i = 0; i < this.gridEvents.length; i++) { - const evt = this.gridEvents[i]; - if (evt.note === note && Math.abs(evt.tick - tick) < hitRadius) { - return i; - } - } - return -1; + if (this._isDragging) { + if (this._dragMode === 'select' && this.selectionRect) { + this.selectionRect.x2 = x; + this.selectionRect.y2 = y; + this.requestRedraw(); + } else if (this._dragMode === 'pan') { + const dx = (x - this._dragStart.x) * this.ticksPerPixel; + const dy = y - this._dragStart.y; + this.scrollX = Math.max(0, this._dragStart.scrollX - dx); + this.scrollY = Math.max(0, this._dragStart.scrollY - dy); + this.requestRedraw(); + this._notifyScrollChange(); + } + return; } - // ======================================================================== - // MOUSE INTERACTION - // ======================================================================== - - _handleMouseDown(e) { - const rect = this.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - // Click on row label area - if (x < this.headerWidth && y > this.topMargin) { - const rowIndex = this._yToRow(y); - if (rowIndex >= 0 && rowIndex < this.visibleNotes.length) { - const note = this.visibleNotes[rowIndex]; - if (x <= 16) { - // Mute toggle zone (left side) - if (this.mutedNotes.has(note)) { - this.mutedNotes.delete(note); - } else { - this.mutedNotes.add(note); - } - this.requestRedraw(); - this._emitEvent('labelclick', { note, muted: this.mutedNotes.has(note) }); - } else { - // Label area: play the drum sound - this._emitEvent('playrow', { note }); - } - return; - } - } - - // Pan mode: pan by default, select with shift - // Select mode: select by default, pan with alt/middle - const forcePan = e.altKey || e.button === 1; - const usePan = forcePan || (this.tool === 'pan' && !e.shiftKey); - - if (usePan) { - this._isDragging = true; - this._dragMode = 'pan'; - this._dragStart = { x, y, scrollX: this.scrollX, scrollY: this.scrollY }; - this.canvas.style.cursor = 'grabbing'; - e.preventDefault(); - return; - } - - const hitIndex = this._hitTest(x, y); - - if (hitIndex >= 0) { - if (e.ctrlKey || e.metaKey) { - if (this.selectedEvents.has(hitIndex)) { - this.selectedEvents.delete(hitIndex); - } else { - this.selectedEvents.add(hitIndex); - } - } else if (!this.selectedEvents.has(hitIndex)) { - this.selectedEvents.clear(); - this.selectedEvents.add(hitIndex); - } - this.requestRedraw(); - this._emitEvent('selectionchange', { selected: this.getSelectedIndices() }); - } else { - if (!e.ctrlKey && !e.metaKey) this.selectedEvents.clear(); - this._isDragging = true; - this._dragMode = 'select'; - this._dragStart = { x, y }; - this.selectionRect = { x1: x, y1: y, x2: x, y2: y }; - this.requestRedraw(); - } + // Cursor for label zone (always clickable for mute toggle) + if (x < this.headerWidth && y > this.topMargin) { + this.canvas.style.cursor = 'pointer'; + } else if (!this._isDragging) { + this.canvas.style.cursor = this.tool === 'pan' ? 'grab' : 'crosshair'; } - _handleMouseMove(e) { - const rect = this.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - if (this._isDragging) { - if (this._dragMode === 'select' && this.selectionRect) { - this.selectionRect.x2 = x; - this.selectionRect.y2 = y; - this.requestRedraw(); - } else if (this._dragMode === 'pan') { - const dx = (x - this._dragStart.x) * this.ticksPerPixel; - const dy = y - this._dragStart.y; - this.scrollX = Math.max(0, this._dragStart.scrollX - dx); - this.scrollY = Math.max(0, this._dragStart.scrollY - dy); - this.requestRedraw(); - this._notifyScrollChange(); - } - return; - } - - // Cursor for label zone (always clickable for mute toggle) - if (x < this.headerWidth && y > this.topMargin) { - this.canvas.style.cursor = 'pointer'; - } else if (!this._isDragging) { - this.canvas.style.cursor = this.tool === 'pan' ? 'grab' : 'crosshair'; - } - - // Hover - const hitIndex = this._hitTest(x, y); - if (hitIndex !== this._hoverEvent) { - this._hoverEvent = hitIndex >= 0 ? hitIndex : null; - this.requestRedraw(); - } + // Hover + const hitIndex = this._hitTest(x, y); + if (hitIndex !== this._hoverEvent) { + this._hoverEvent = hitIndex >= 0 ? hitIndex : null; + this.requestRedraw(); } - - _handleMouseUp(_e) { - if (this._isDragging && this._dragMode === 'select' && this.selectionRect) { - const r = this.selectionRect; - const minX = Math.min(r.x1, r.x2); - const maxX = Math.max(r.x1, r.x2); - const minY = Math.min(r.y1, r.y2); - const maxY = Math.max(r.y1, r.y2); - - for (let i = 0; i < this.gridEvents.length; i++) { - const evt = this.gridEvents[i]; - const rowIndex = this.visibleNotes.indexOf(evt.note); - if (rowIndex < 0) continue; - const evtX = this._tickToX(evt.tick); - const evtY = this._rowToY(rowIndex) + this.rowHeight / 2; - - if (evtX >= minX && evtX <= maxX && evtY >= minY && evtY <= maxY) { - this.selectedEvents.add(i); - } - } - this._emitEvent('selectionchange', { selected: this.getSelectedIndices() }); + } + + _handleMouseUp(_e) { + if (this._isDragging && this._dragMode === 'select' && this.selectionRect) { + const r = this.selectionRect; + const minX = Math.min(r.x1, r.x2); + const maxX = Math.max(r.x1, r.x2); + const minY = Math.min(r.y1, r.y2); + const maxY = Math.max(r.y1, r.y2); + + for (let i = 0; i < this.gridEvents.length; i++) { + const evt = this.gridEvents[i]; + const rowIndex = this.visibleNotes.indexOf(evt.note); + if (rowIndex < 0) continue; + const evtX = this._tickToX(evt.tick); + const evtY = this._rowToY(rowIndex) + this.rowHeight / 2; + + if (evtX >= minX && evtX <= maxX && evtY >= minY && evtY <= maxY) { + this.selectedEvents.add(i); } - - this._isDragging = false; - this._dragMode = null; - this._dragStart = null; - this.selectionRect = null; - this.canvas.style.cursor = this.tool === 'pan' ? 'grab' : 'crosshair'; - this.requestRedraw(); + } + this._emitEvent('selectionchange', { selected: this.getSelectedIndices() }); } - _handleDblClick(e) { - const rect = this.canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const hitIndex = this._hitTest(x, y); - - if (hitIndex >= 0) { - // Double-click existing: edit velocity - this._emitEvent('editvelocity', { index: hitIndex, event: this.gridEvents[hitIndex] }); - } else { - // Double-click empty: add hit - const tick = this._xToTick(x); - const note = this._yToNote(y); - if (note >= 0 && tick >= 0) { - // Quantize to current subdivision - const ticksPerDiv = this.ticksPerBeat / this.quantizeDiv; - const quantizedTick = Math.round(tick / ticksPerDiv) * ticksPerDiv; - this._emitEvent('addhit', { tick: quantizedTick, note }); - } - } + this._isDragging = false; + this._dragMode = null; + this._dragStart = null; + this.selectionRect = null; + this.canvas.style.cursor = this.tool === 'pan' ? 'grab' : 'crosshair'; + this.requestRedraw(); + } + + _handleDblClick(e) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const hitIndex = this._hitTest(x, y); + + if (hitIndex >= 0) { + // Double-click existing: edit velocity + this._emitEvent('editvelocity', { index: hitIndex, event: this.gridEvents[hitIndex] }); + } else { + // Double-click empty: add hit + const tick = this._xToTick(x); + const note = this._yToNote(y); + if (note >= 0 && tick >= 0) { + // Quantize to current subdivision + const ticksPerDiv = this.ticksPerBeat / this.quantizeDiv; + const quantizedTick = Math.round(tick / ticksPerDiv) * ticksPerDiv; + this._emitEvent('addhit', { tick: quantizedTick, note }); + } } + } - // ======================================================================== - // WHEEL SCROLL - // ======================================================================== + // ======================================================================== + // WHEEL SCROLL + // ======================================================================== - _handleWheel(e) { - e.preventDefault(); + _handleWheel(e) { + e.preventDefault(); - const maxScrollY = Math.max(0, this.getRequiredHeight() - this.canvas.height); - - if (e.shiftKey) { - // Horizontal scroll - this.scrollX = Math.max(0, this.scrollX + e.deltaY * this.ticksPerPixel); - } else { - // Vertical scroll - this.scrollY = Math.max(0, Math.min(maxScrollY, this.scrollY + e.deltaY)); - } + const maxScrollY = Math.max(0, this.getRequiredHeight() - this.canvas.height); - this.requestRedraw(); - this._notifyScrollChange(); + if (e.shiftKey) { + // Horizontal scroll + this.scrollX = Math.max(0, this.scrollX + e.deltaY * this.ticksPerPixel); + } else { + // Vertical scroll + this.scrollY = Math.max(0, Math.min(maxScrollY, this.scrollY + e.deltaY)); } - // ======================================================================== - // SCROLLBAR OVERLAY - // ======================================================================== - - /** - * Draw a vertical scrollbar overlay when content exceeds canvas height - */ - _drawScrollbar(w, h) { - const totalHeight = this.getRequiredHeight(); - if (totalHeight <= h) return; // No scrollbar needed - - const ctx = this.ctx; - const scrollbarWidth = 6; - const scrollbarX = w - scrollbarWidth - 2; - const maxScrollY = totalHeight - h; - const trackHeight = h - this.topMargin - 4; - const thumbRatio = h / totalHeight; - const thumbHeight = Math.max(20, trackHeight * thumbRatio); - const thumbY = this.topMargin + 2 + (this.scrollY / maxScrollY) * (trackHeight - thumbHeight); - - // Track - ctx.fillStyle = 'rgba(128, 128, 128, 0.15)'; - ctx.fillRect(scrollbarX, this.topMargin + 2, scrollbarWidth, trackHeight); - - // Thumb - ctx.fillStyle = 'rgba(128, 128, 128, 0.4)'; - ctx.beginPath(); - ctx.roundRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight, 3); - ctx.fill(); - } - - // ======================================================================== - // EVENT EMITTER - // ======================================================================== - - _emitEvent(type, detail) { - this.canvas.dispatchEvent(new CustomEvent(`drum:${type}`, { detail, bubbles: true })); - } - - // ======================================================================== - // CLEANUP - // ======================================================================== - - // destroy() inherited from CanvasRenderer. + this.requestRedraw(); + this._notifyScrollChange(); + } + + // ======================================================================== + // SCROLLBAR OVERLAY + // ======================================================================== + + /** + * Draw a vertical scrollbar overlay when content exceeds canvas height + */ + _drawScrollbar(w, h) { + const totalHeight = this.getRequiredHeight(); + if (totalHeight <= h) return; // No scrollbar needed + + const ctx = this.ctx; + const scrollbarWidth = 6; + const scrollbarX = w - scrollbarWidth - 2; + const maxScrollY = totalHeight - h; + const trackHeight = h - this.topMargin - 4; + const thumbRatio = h / totalHeight; + const thumbHeight = Math.max(20, trackHeight * thumbRatio); + const thumbY = this.topMargin + 2 + (this.scrollY / maxScrollY) * (trackHeight - thumbHeight); + + // Track + ctx.fillStyle = 'rgba(128, 128, 128, 0.15)'; + ctx.fillRect(scrollbarX, this.topMargin + 2, scrollbarWidth, trackHeight); + + // Thumb + ctx.fillStyle = 'rgba(128, 128, 128, 0.4)'; + ctx.beginPath(); + ctx.roundRect(scrollbarX, thumbY, scrollbarWidth, thumbHeight, 3); + ctx.fill(); + } + + // ======================================================================== + // EVENT EMITTER + // ======================================================================== + + _emitEvent(type, detail) { + this.canvas.dispatchEvent(new CustomEvent(`drum:${type}`, { detail, bubbles: true })); + } + + // ======================================================================== + // CLEANUP + // ======================================================================== + + // destroy() inherited from CanvasRenderer. } // ============================================================================ // EXPORT // ============================================================================ if (typeof module !== 'undefined' && module.exports) { - module.exports = DrumGridRenderer; + module.exports = DrumGridRenderer; } if (typeof window !== 'undefined') { - window.DrumGridRenderer = DrumGridRenderer; + window.DrumGridRenderer = DrumGridRenderer; } diff --git a/public/js/features/DrumToolsPanel.js b/public/js/features/DrumToolsPanel.js index c44878a16..c375a15e6 100644 --- a/public/js/features/DrumToolsPanel.js +++ b/public/js/features/DrumToolsPanel.js @@ -5,29 +5,29 @@ // ============================================================================ class DrumToolsPanel { - constructor(containerEl, options = {}) { - this.containerEl = containerEl; - this.gridRenderer = null; // Set by DrumPatternEditor after grid init - this.onChanged = options.onChanged || null; // Callback after any transform + constructor(containerEl, options = {}) { + this.containerEl = containerEl; + this.gridRenderer = null; // Set by DrumPatternEditor after grid init + this.onChanged = options.onChanged || null; // Callback after any transform - this._createDOM(); - this._attachEvents(); - } + this._createDOM(); + this._attachEvents(); + } - // ======================================================================== - // I18N - // ======================================================================== + // ======================================================================== + // I18N + // ======================================================================== - t(key, params = {}) { - return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; - } + t(key, params = {}) { + return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + } - // ======================================================================== - // DOM - // ======================================================================== + // ======================================================================== + // DOM + // ======================================================================== - _createDOM() { - this.containerEl.innerHTML = ` + _createDOM() { + this.containerEl.innerHTML = `
`; + } + + // ======================================================================== + // EVENTS + // ======================================================================== + + _attachEvents() { + this.containerEl.addEventListener('click', (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + this._handleAction(btn.dataset.action); + }); + + // Slider live updates + const humanizeSlider = this.containerEl.querySelector('#drum-humanize-amount'); + if (humanizeSlider) { + humanizeSlider.addEventListener('input', () => { + const val = humanizeSlider.value; + this.containerEl.querySelector('#drum-humanize-val').textContent = `±${val}`; + }); } - // ======================================================================== - // EVENTS - // ======================================================================== - - _attachEvents() { - this.containerEl.addEventListener('click', (e) => { - const btn = e.target.closest('[data-action]'); - if (!btn) return; - this._handleAction(btn.dataset.action); - }); - - // Slider live updates - const humanizeSlider = this.containerEl.querySelector('#drum-humanize-amount'); - if (humanizeSlider) { - humanizeSlider.addEventListener('input', () => { - const val = humanizeSlider.value; - this.containerEl.querySelector('#drum-humanize-val').textContent = `±${val}`; - }); - } - - const scaleSlider = this.containerEl.querySelector('#drum-vel-scale'); - if (scaleSlider) { - scaleSlider.addEventListener('input', () => { - const val = scaleSlider.value; - this.containerEl.querySelector('#drum-vel-scale-val').textContent = `${val}%`; - }); - } + const scaleSlider = this.containerEl.querySelector('#drum-vel-scale'); + if (scaleSlider) { + scaleSlider.addEventListener('input', () => { + const val = scaleSlider.value; + this.containerEl.querySelector('#drum-vel-scale-val').textContent = `${val}%`; + }); } - - _handleAction(action) { - if (!this.gridRenderer) return; - - switch (action) { - case 'humanize': { - const amount = parseInt(this.containerEl.querySelector('#drum-humanize-amount')?.value || '10', 10); - this.applyHumanize(amount); - break; - } - case 'accent': - this.applyAccent(); - break; - case 'apply-scale': { - const percent = parseInt(this.containerEl.querySelector('#drum-vel-scale')?.value || '100', 10); - this.applyVelocityScale(percent); - // Reset slider after applying - const slider = this.containerEl.querySelector('#drum-vel-scale'); - if (slider) slider.value = 100; - this.containerEl.querySelector('#drum-vel-scale-val').textContent = '100%'; - break; - } - case 'crescendo': - this.applyCrescendo(40, 120); - break; - case 'decrescendo': - this.applyCrescendo(120, 40); - break; - } + } + + _handleAction(action) { + if (!this.gridRenderer) return; + + switch (action) { + case 'humanize': { + const amount = parseInt( + this.containerEl.querySelector('#drum-humanize-amount')?.value || '10', + 10 + ); + this.applyHumanize(amount); + break; + } + case 'accent': + this.applyAccent(); + break; + case 'apply-scale': { + const percent = parseInt( + this.containerEl.querySelector('#drum-vel-scale')?.value || '100', + 10 + ); + this.applyVelocityScale(percent); + // Reset slider after applying + const slider = this.containerEl.querySelector('#drum-vel-scale'); + if (slider) slider.value = 100; + this.containerEl.querySelector('#drum-vel-scale-val').textContent = '100%'; + break; + } + case 'crescendo': + this.applyCrescendo(40, 120); + break; + case 'decrescendo': + this.applyCrescendo(120, 40); + break; } - - // ======================================================================== - // VELOCITY TRANSFORMS - // ======================================================================== - - /** - * Get target events: selected if any, otherwise all - */ - _getTargetEvents() { - const gr = this.gridRenderer; - if (gr.selectedEvents.size > 0) { - return gr.getSelectedEvents(); - } - return gr.gridEvents; + } + + // ======================================================================== + // VELOCITY TRANSFORMS + // ======================================================================== + + /** + * Get target events: selected if any, otherwise all + */ + _getTargetEvents() { + const gr = this.gridRenderer; + if (gr.selectedEvents.size > 0) { + return gr.getSelectedEvents(); } + return gr.gridEvents; + } - _clampVelocity(v) { - return Math.max(1, Math.min(127, Math.round(v))); - } + _clampVelocity(v) { + return Math.max(1, Math.min(127, Math.round(v))); + } - _emitChanged() { - if (this.gridRenderer) { - this.gridRenderer.redraw(); - } - if (this.onChanged) { - this.onChanged(); - } + _emitChanged() { + if (this.gridRenderer) { + this.gridRenderer.redraw(); } + if (this.onChanged) { + this.onChanged(); + } + } - /** - * Humanize: add random velocity variation and optional timing jitter - */ - applyHumanize(amount) { - const events = this._getTargetEvents(); - if (events.length === 0) return; - - this.gridRenderer.saveSnapshot(); + /** + * Humanize: add random velocity variation and optional timing jitter + */ + applyHumanize(amount) { + const events = this._getTargetEvents(); + if (events.length === 0) return; - const tickJitter = Math.round(amount * 2); // Small timing jitter + this.gridRenderer.saveSnapshot(); - for (const evt of events) { - // Velocity randomization - const velDelta = Math.round((Math.random() * 2 - 1) * amount); - evt.velocity = this._clampVelocity(evt.velocity + velDelta); + const tickJitter = Math.round(amount * 2); // Small timing jitter - // Timing jitter (small) - if (tickJitter > 0) { - const tickDelta = Math.round((Math.random() * 2 - 1) * tickJitter); - evt.tick = Math.max(0, evt.tick + tickDelta); - } - } + for (const evt of events) { + // Velocity randomization + const velDelta = Math.round((Math.random() * 2 - 1) * amount); + evt.velocity = this._clampVelocity(evt.velocity + velDelta); - this._emitChanged(); + // Timing jitter (small) + if (tickJitter > 0) { + const tickDelta = Math.round((Math.random() * 2 - 1) * tickJitter); + evt.tick = Math.max(0, evt.tick + tickDelta); + } } - /** - * Accent downbeats: beats 1&3 get +20, beats 2&4 get -10 - */ - applyAccent() { - const events = this._getTargetEvents(); - if (events.length === 0) return; + this._emitChanged(); + } - this.gridRenderer.saveSnapshot(); + /** + * Accent downbeats: beats 1&3 get +20, beats 2&4 get -10 + */ + applyAccent() { + const events = this._getTargetEvents(); + if (events.length === 0) return; - const tpb = this.gridRenderer.ticksPerBeat || 480; + this.gridRenderer.saveSnapshot(); - for (const evt of events) { - const beatInMeasure = Math.floor(evt.tick / tpb) % (this.gridRenderer.beatsPerMeasure || 4); + const tpb = this.gridRenderer.ticksPerBeat || 480; - if (beatInMeasure === 0 || beatInMeasure === 2) { - // Beats 1 & 3: accent - evt.velocity = this._clampVelocity(evt.velocity + 20); - } else { - // Beats 2 & 4: soften - evt.velocity = this._clampVelocity(evt.velocity - 10); - } - } + for (const evt of events) { + const beatInMeasure = Math.floor(evt.tick / tpb) % (this.gridRenderer.beatsPerMeasure || 4); - this._emitChanged(); + if (beatInMeasure === 0 || beatInMeasure === 2) { + // Beats 1 & 3: accent + evt.velocity = this._clampVelocity(evt.velocity + 20); + } else { + // Beats 2 & 4: soften + evt.velocity = this._clampVelocity(evt.velocity - 10); + } } - /** - * Scale all velocities by a percentage - */ - applyVelocityScale(percent) { - if (percent === 100) return; + this._emitChanged(); + } - const events = this._getTargetEvents(); - if (events.length === 0) return; + /** + * Scale all velocities by a percentage + */ + applyVelocityScale(percent) { + if (percent === 100) return; - this.gridRenderer.saveSnapshot(); + const events = this._getTargetEvents(); + if (events.length === 0) return; - for (const evt of events) { - evt.velocity = this._clampVelocity(evt.velocity * percent / 100); - } + this.gridRenderer.saveSnapshot(); - this._emitChanged(); + for (const evt of events) { + evt.velocity = this._clampVelocity((evt.velocity * percent) / 100); } - /** - * Crescendo/Decrescendo: linear velocity interpolation across time range - */ - applyCrescendo(startVel, endVel) { - const events = this._getTargetEvents(); - if (events.length < 2) return; + this._emitChanged(); + } - this.gridRenderer.saveSnapshot(); + /** + * Crescendo/Decrescendo: linear velocity interpolation across time range + */ + applyCrescendo(startVel, endVel) { + const events = this._getTargetEvents(); + if (events.length < 2) return; - // Sort by tick to find range - const sorted = [...events].sort((a, b) => a.tick - b.tick); - const minTick = sorted[0].tick; - const maxTick = sorted[sorted.length - 1].tick; - const range = maxTick - minTick; + this.gridRenderer.saveSnapshot(); - if (range === 0) return; + // Sort by tick to find range + const sorted = [...events].sort((a, b) => a.tick - b.tick); + const minTick = sorted[0].tick; + const maxTick = sorted[sorted.length - 1].tick; + const range = maxTick - minTick; - for (const evt of events) { - const t = (evt.tick - minTick) / range; // 0..1 - const vel = startVel + (endVel - startVel) * t; - evt.velocity = this._clampVelocity(vel); - } + if (range === 0) return; - this._emitChanged(); + for (const evt of events) { + const t = (evt.tick - minTick) / range; // 0..1 + const vel = startVel + (endVel - startVel) * t; + evt.velocity = this._clampVelocity(vel); } - // ======================================================================== - // LIFECYCLE - // ======================================================================== + this._emitChanged(); + } - setGridRenderer(gridRenderer) { - this.gridRenderer = gridRenderer; - } + // ======================================================================== + // LIFECYCLE + // ======================================================================== - updateTheme() { - // DOM-based panel uses CSS variables, no manual update needed - } + setGridRenderer(gridRenderer) { + this.gridRenderer = gridRenderer; + } - destroy() { - this.gridRenderer = null; - this.containerEl.innerHTML = ''; - } + updateTheme() { + // DOM-based panel uses CSS variables, no manual update needed + } + + destroy() { + this.gridRenderer = null; + this.containerEl.innerHTML = ''; + } } // ============================================================================ // EXPORT // ============================================================================ if (typeof module !== 'undefined' && module.exports) { - module.exports = DrumToolsPanel; + module.exports = DrumToolsPanel; } if (typeof window !== 'undefined') { - window.DrumToolsPanel = DrumToolsPanel; + window.DrumToolsPanel = DrumToolsPanel; } diff --git a/public/js/features/GmInstrumentCapabilities.js b/public/js/features/GmInstrumentCapabilities.js index dded28971..bb0abcd83 100644 --- a/public/js/features/GmInstrumentCapabilities.js +++ b/public/js/features/GmInstrumentCapabilities.js @@ -19,152 +19,1176 @@ // - get(gmProgram) → entrée ou null // ============================================================================ -(function() { - 'use strict'; +(function () { + 'use strict'; - const CAPABILITIES = { - 0: { name: "Acoustic Grand Piano", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 1: { name: "Bright Acoustic Piano", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 2: { name: "Electric Grand Piano", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 3: { name: "Honky-tonk Piano", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 4: { name: "Electric Piano 1", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 5: { name: "Electric Piano 2", rangeMin: 21, rangeMax: 108, comfortMin: 28, comfortMax: 96, polyphony: 16, monophonic: false }, - 6: { name: "Harpsichord", rangeMin: 29, rangeMax: 89, comfortMin: 36, comfortMax: 84, polyphony: 8, monophonic: false }, - 7: { name: "Clavinet", rangeMin: 28, rangeMax: 88, comfortMin: 36, comfortMax: 84, polyphony: 8, monophonic: false }, - 8: { name: "Celesta", rangeMin: 60, rangeMax: 108, comfortMin: 60, comfortMax: 96, polyphony: 8, monophonic: false }, - 9: { name: "Glockenspiel", rangeMin: 79, rangeMax: 108, comfortMin: 79, comfortMax: 105, polyphony: 4, monophonic: false }, - 10: { name: "Music Box", rangeMin: 72, rangeMax: 96, comfortMin: 72, comfortMax: 96, polyphony: 4, monophonic: false }, - 11: { name: "Vibraphone", rangeMin: 53, rangeMax: 89, comfortMin: 53, comfortMax: 89, polyphony: 6, monophonic: false }, - 12: { name: "Marimba", rangeMin: 45, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 4, monophonic: false }, - 13: { name: "Xylophone", rangeMin: 65, rangeMax: 108, comfortMin: 65, comfortMax: 105, polyphony: 4, monophonic: false }, - 14: { name: "Tubular Bells", rangeMin: 60, rangeMax: 89, comfortMin: 60, comfortMax: 89, polyphony: 8, monophonic: false }, - 15: { name: "Dulcimer", rangeMin: 48, rangeMax: 84, comfortMin: 50, comfortMax: 81, polyphony: 4, monophonic: false }, - 16: { name: "Drawbar Organ", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 16, monophonic: false }, - 17: { name: "Percussive Organ", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 16, monophonic: false }, - 18: { name: "Rock Organ", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 16, monophonic: false }, - 19: { name: "Church Organ", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 16, monophonic: false }, - 20: { name: "Reed Organ", rangeMin: 36, rangeMax: 96, comfortMin: 41, comfortMax: 89, polyphony: 16, monophonic: false }, - 21: { name: "Accordion", rangeMin: 41, rangeMax: 96, comfortMin: 48, comfortMax: 89, polyphony: 8, monophonic: false }, - 22: { name: "Harmonica", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 1, monophonic: true }, - 23: { name: "Tango Accordion", rangeMin: 41, rangeMax: 96, comfortMin: 48, comfortMax: 89, polyphony: 8, monophonic: false }, - 24: { name: "Acoustic Guitar (nylon)", rangeMin: 40, rangeMax: 84, comfortMin: 40, comfortMax: 79, polyphony: 6, monophonic: false }, - 25: { name: "Acoustic Guitar (steel)", rangeMin: 40, rangeMax: 84, comfortMin: 40, comfortMax: 79, polyphony: 6, monophonic: false }, - 26: { name: "Electric Guitar (jazz)", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 27: { name: "Electric Guitar (clean)", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 28: { name: "Electric Guitar (muted)", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 29: { name: "Overdriven Guitar", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 30: { name: "Distortion Guitar", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 31: { name: "Guitar Harmonics", rangeMin: 40, rangeMax: 88, comfortMin: 40, comfortMax: 84, polyphony: 6, monophonic: false }, - 32: { name: "Acoustic Bass", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 33: { name: "Electric Bass (finger)", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 34: { name: "Electric Bass (pick)", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 35: { name: "Fretless Bass", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 36: { name: "Slap Bass 1", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 37: { name: "Slap Bass 2", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 38: { name: "Synth Bass 1", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 39: { name: "Synth Bass 2", rangeMin: 28, rangeMax: 67, comfortMin: 28, comfortMax: 60, polyphony: 1, monophonic: true }, - 40: { name: "Violin", rangeMin: 55, rangeMax: 103, comfortMin: 55, comfortMax: 96, polyphony: 4, monophonic: false }, - 41: { name: "Viola", rangeMin: 48, rangeMax: 91, comfortMin: 48, comfortMax: 84, polyphony: 4, monophonic: false }, - 42: { name: "Cello", rangeMin: 36, rangeMax: 84, comfortMin: 36, comfortMax: 76, polyphony: 4, monophonic: false }, - 43: { name: "Contrabass", rangeMin: 28, rangeMax: 60, comfortMin: 28, comfortMax: 55, polyphony: 4, monophonic: false }, - 44: { name: "Tremolo Strings", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 8, monophonic: false }, - 45: { name: "Pizzicato Strings", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 8, monophonic: false }, - 46: { name: "Orchestral Harp", rangeMin: 24, rangeMax: 103, comfortMin: 24, comfortMax: 100, polyphony: 8, monophonic: false }, - 47: { name: "Timpani", rangeMin: 36, rangeMax: 57, comfortMin: 38, comfortMax: 53, polyphony: 2, monophonic: false }, - 48: { name: "String Ensemble 1", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 16, monophonic: false }, - 49: { name: "String Ensemble 2", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 16, monophonic: false }, - 50: { name: "Synth Strings 1", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 16, monophonic: false }, - 51: { name: "Synth Strings 2", rangeMin: 28, rangeMax: 100, comfortMin: 36, comfortMax: 91, polyphony: 16, monophonic: false }, - 52: { name: "Choir Aahs", rangeMin: 40, rangeMax: 84, comfortMin: 48, comfortMax: 79, polyphony: 16, monophonic: false }, - 53: { name: "Voice Oohs", rangeMin: 40, rangeMax: 84, comfortMin: 48, comfortMax: 79, polyphony: 16, monophonic: false }, - 54: { name: "Synth Voice", rangeMin: 40, rangeMax: 84, comfortMin: 48, comfortMax: 79, polyphony: 16, monophonic: false }, - 55: { name: "Orchestra Hit", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 56: { name: "Trumpet", rangeMin: 52, rangeMax: 84, comfortMin: 55, comfortMax: 79, polyphony: 1, monophonic: true }, - 57: { name: "Trombone", rangeMin: 40, rangeMax: 72, comfortMin: 43, comfortMax: 67, polyphony: 1, monophonic: true }, - 58: { name: "Tuba", rangeMin: 28, rangeMax: 58, comfortMin: 33, comfortMax: 55, polyphony: 1, monophonic: true }, - 59: { name: "Muted Trumpet", rangeMin: 52, rangeMax: 82, comfortMin: 55, comfortMax: 77, polyphony: 1, monophonic: true }, - 60: { name: "French Horn", rangeMin: 34, rangeMax: 77, comfortMin: 41, comfortMax: 72, polyphony: 1, monophonic: true }, - 61: { name: "Brass Section", rangeMin: 40, rangeMax: 84, comfortMin: 48, comfortMax: 77, polyphony: 8, monophonic: false }, - 62: { name: "Synth Brass 1", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 63: { name: "Synth Brass 2", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 64: { name: "Soprano Sax", rangeMin: 56, rangeMax: 87, comfortMin: 59, comfortMax: 84, polyphony: 1, monophonic: true }, - 65: { name: "Alto Sax", rangeMin: 49, rangeMax: 80, comfortMin: 52, comfortMax: 77, polyphony: 1, monophonic: true }, - 66: { name: "Tenor Sax", rangeMin: 44, rangeMax: 75, comfortMin: 47, comfortMax: 72, polyphony: 1, monophonic: true }, - 67: { name: "Baritone Sax", rangeMin: 36, rangeMax: 68, comfortMin: 39, comfortMax: 65, polyphony: 1, monophonic: true }, - 68: { name: "Oboe", rangeMin: 58, rangeMax: 91, comfortMin: 60, comfortMax: 86, polyphony: 1, monophonic: true }, - 69: { name: "English Horn", rangeMin: 52, rangeMax: 81, comfortMin: 55, comfortMax: 77, polyphony: 1, monophonic: true }, - 70: { name: "Bassoon", rangeMin: 34, rangeMax: 72, comfortMin: 38, comfortMax: 67, polyphony: 1, monophonic: true }, - 71: { name: "Clarinet", rangeMin: 50, rangeMax: 91, comfortMin: 52, comfortMax: 86, polyphony: 1, monophonic: true }, - 72: { name: "Piccolo", rangeMin: 74, rangeMax: 108, comfortMin: 76, comfortMax: 103, polyphony: 1, monophonic: true }, - 73: { name: "Flute", rangeMin: 60, rangeMax: 96, comfortMin: 62, comfortMax: 91, polyphony: 1, monophonic: true }, - 74: { name: "Recorder", rangeMin: 60, rangeMax: 86, comfortMin: 62, comfortMax: 84, polyphony: 1, monophonic: true }, - 75: { name: "Pan Flute", rangeMin: 60, rangeMax: 84, comfortMin: 62, comfortMax: 79, polyphony: 1, monophonic: true }, - 76: { name: "Blown Bottle", rangeMin: 60, rangeMax: 84, comfortMin: 62, comfortMax: 79, polyphony: 1, monophonic: true }, - 77: { name: "Shakuhachi", rangeMin: 55, rangeMax: 84, comfortMin: 57, comfortMax: 79, polyphony: 1, monophonic: true }, - 78: { name: "Whistle", rangeMin: 60, rangeMax: 96, comfortMin: 64, comfortMax: 91, polyphony: 1, monophonic: true }, - 79: { name: "Ocarina", rangeMin: 60, rangeMax: 84, comfortMin: 62, comfortMax: 79, polyphony: 1, monophonic: true }, - 80: { name: "Lead 1 (square)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 81: { name: "Lead 2 (sawtooth)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 82: { name: "Lead 3 (calliope)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 83: { name: "Lead 4 (chiff)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 84: { name: "Lead 5 (charang)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 85: { name: "Lead 6 (voice)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 1, monophonic: true }, - 86: { name: "Lead 7 (fifths)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 2, monophonic: false }, - 87: { name: "Lead 8 (bass + lead)", rangeMin: 36, rangeMax: 96, comfortMin: 48, comfortMax: 91, polyphony: 2, monophonic: false }, - 88: { name: "Pad 1 (new age)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 89: { name: "Pad 2 (warm)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 90: { name: "Pad 3 (polysynth)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 91: { name: "Pad 4 (choir)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 92: { name: "Pad 5 (bowed)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 93: { name: "Pad 6 (metallic)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 94: { name: "Pad 7 (halo)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 95: { name: "Pad 8 (sweep)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 8, monophonic: false }, - 96: { name: "FX 1 (rain)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 97: { name: "FX 2 (soundtrack)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 98: { name: "FX 3 (crystal)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 99: { name: "FX 4 (atmosphere)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 100: { name: "FX 5 (brightness)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 101: { name: "FX 6 (goblins)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 102: { name: "FX 7 (echoes)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 103: { name: "FX 8 (sci-fi)", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 4, monophonic: false }, - 104: { name: "Sitar", rangeMin: 48, rangeMax: 84, comfortMin: 48, comfortMax: 79, polyphony: 4, monophonic: false }, - 105: { name: "Banjo", rangeMin: 50, rangeMax: 86, comfortMin: 50, comfortMax: 81, polyphony: 6, monophonic: false }, - 106: { name: "Shamisen", rangeMin: 50, rangeMax: 84, comfortMin: 50, comfortMax: 79, polyphony: 4, monophonic: false }, - 107: { name: "Koto", rangeMin: 43, rangeMax: 84, comfortMin: 43, comfortMax: 79, polyphony: 4, monophonic: false }, - 108: { name: "Kalimba", rangeMin: 53, rangeMax: 84, comfortMin: 55, comfortMax: 79, polyphony: 4, monophonic: false }, - 109: { name: "Bagpipe", rangeMin: 62, rangeMax: 86, comfortMin: 62, comfortMax: 81, polyphony: 1, monophonic: true }, - 110: { name: "Fiddle", rangeMin: 55, rangeMax: 103, comfortMin: 55, comfortMax: 96, polyphony: 4, monophonic: false }, - 111: { name: "Shanai", rangeMin: 62, rangeMax: 86, comfortMin: 64, comfortMax: 81, polyphony: 1, monophonic: true }, - 112: { name: "Tinkle Bell", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 113: { name: "Agogo", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 114: { name: "Steel Drums", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 2, monophonic: false }, - 115: { name: "Woodblock", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 2, monophonic: false }, - 116: { name: "Taiko Drum", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 117: { name: "Melodic Tom", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 118: { name: "Synth Drum", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 119: { name: "Reverse Cymbal", rangeMin: 48, rangeMax: 96, comfortMin: 53, comfortMax: 89, polyphony: 4, monophonic: false }, - 120: { name: "Guitar Fret Noise", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 121: { name: "Breath Noise", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 122: { name: "Seashore", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 123: { name: "Bird Tweet", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 124: { name: "Telephone Ring", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 125: { name: "Helicopter", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 126: { name: "Applause", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - 127: { name: "Gunshot", rangeMin: 36, rangeMax: 96, comfortMin: 36, comfortMax: 96, polyphony: 1, monophonic: true }, - }; - - /** - * Capability entry for a GM program, or null when out of range / unknown. - * @param {number} gmProgram - GM program number (0-127) - * @returns {?{name:string,rangeMin:number,rangeMax:number,comfortMin:number,comfortMax:number,polyphony:number,monophonic:boolean}} - */ - function get(gmProgram) { - if (!Number.isFinite(gmProgram)) return null; - return CAPABILITIES[gmProgram] || null; + const CAPABILITIES = { + 0: { + name: 'Acoustic Grand Piano', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 1: { + name: 'Bright Acoustic Piano', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 2: { + name: 'Electric Grand Piano', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 3: { + name: 'Honky-tonk Piano', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 4: { + name: 'Electric Piano 1', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 5: { + name: 'Electric Piano 2', + rangeMin: 21, + rangeMax: 108, + comfortMin: 28, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 6: { + name: 'Harpsichord', + rangeMin: 29, + rangeMax: 89, + comfortMin: 36, + comfortMax: 84, + polyphony: 8, + monophonic: false + }, + 7: { + name: 'Clavinet', + rangeMin: 28, + rangeMax: 88, + comfortMin: 36, + comfortMax: 84, + polyphony: 8, + monophonic: false + }, + 8: { + name: 'Celesta', + rangeMin: 60, + rangeMax: 108, + comfortMin: 60, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 9: { + name: 'Glockenspiel', + rangeMin: 79, + rangeMax: 108, + comfortMin: 79, + comfortMax: 105, + polyphony: 4, + monophonic: false + }, + 10: { + name: 'Music Box', + rangeMin: 72, + rangeMax: 96, + comfortMin: 72, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 11: { + name: 'Vibraphone', + rangeMin: 53, + rangeMax: 89, + comfortMin: 53, + comfortMax: 89, + polyphony: 6, + monophonic: false + }, + 12: { + name: 'Marimba', + rangeMin: 45, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 4, + monophonic: false + }, + 13: { + name: 'Xylophone', + rangeMin: 65, + rangeMax: 108, + comfortMin: 65, + comfortMax: 105, + polyphony: 4, + monophonic: false + }, + 14: { + name: 'Tubular Bells', + rangeMin: 60, + rangeMax: 89, + comfortMin: 60, + comfortMax: 89, + polyphony: 8, + monophonic: false + }, + 15: { + name: 'Dulcimer', + rangeMin: 48, + rangeMax: 84, + comfortMin: 50, + comfortMax: 81, + polyphony: 4, + monophonic: false + }, + 16: { + name: 'Drawbar Organ', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 17: { + name: 'Percussive Organ', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 18: { + name: 'Rock Organ', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 19: { + name: 'Church Organ', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 16, + monophonic: false + }, + 20: { + name: 'Reed Organ', + rangeMin: 36, + rangeMax: 96, + comfortMin: 41, + comfortMax: 89, + polyphony: 16, + monophonic: false + }, + 21: { + name: 'Accordion', + rangeMin: 41, + rangeMax: 96, + comfortMin: 48, + comfortMax: 89, + polyphony: 8, + monophonic: false + }, + 22: { + name: 'Harmonica', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 1, + monophonic: true + }, + 23: { + name: 'Tango Accordion', + rangeMin: 41, + rangeMax: 96, + comfortMin: 48, + comfortMax: 89, + polyphony: 8, + monophonic: false + }, + 24: { + name: 'Acoustic Guitar (nylon)', + rangeMin: 40, + rangeMax: 84, + comfortMin: 40, + comfortMax: 79, + polyphony: 6, + monophonic: false + }, + 25: { + name: 'Acoustic Guitar (steel)', + rangeMin: 40, + rangeMax: 84, + comfortMin: 40, + comfortMax: 79, + polyphony: 6, + monophonic: false + }, + 26: { + name: 'Electric Guitar (jazz)', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 27: { + name: 'Electric Guitar (clean)', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 28: { + name: 'Electric Guitar (muted)', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 29: { + name: 'Overdriven Guitar', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 30: { + name: 'Distortion Guitar', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 31: { + name: 'Guitar Harmonics', + rangeMin: 40, + rangeMax: 88, + comfortMin: 40, + comfortMax: 84, + polyphony: 6, + monophonic: false + }, + 32: { + name: 'Acoustic Bass', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 33: { + name: 'Electric Bass (finger)', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 34: { + name: 'Electric Bass (pick)', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 35: { + name: 'Fretless Bass', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 36: { + name: 'Slap Bass 1', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 37: { + name: 'Slap Bass 2', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 38: { + name: 'Synth Bass 1', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 39: { + name: 'Synth Bass 2', + rangeMin: 28, + rangeMax: 67, + comfortMin: 28, + comfortMax: 60, + polyphony: 1, + monophonic: true + }, + 40: { + name: 'Violin', + rangeMin: 55, + rangeMax: 103, + comfortMin: 55, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 41: { + name: 'Viola', + rangeMin: 48, + rangeMax: 91, + comfortMin: 48, + comfortMax: 84, + polyphony: 4, + monophonic: false + }, + 42: { + name: 'Cello', + rangeMin: 36, + rangeMax: 84, + comfortMin: 36, + comfortMax: 76, + polyphony: 4, + monophonic: false + }, + 43: { + name: 'Contrabass', + rangeMin: 28, + rangeMax: 60, + comfortMin: 28, + comfortMax: 55, + polyphony: 4, + monophonic: false + }, + 44: { + name: 'Tremolo Strings', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 8, + monophonic: false + }, + 45: { + name: 'Pizzicato Strings', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 8, + monophonic: false + }, + 46: { + name: 'Orchestral Harp', + rangeMin: 24, + rangeMax: 103, + comfortMin: 24, + comfortMax: 100, + polyphony: 8, + monophonic: false + }, + 47: { + name: 'Timpani', + rangeMin: 36, + rangeMax: 57, + comfortMin: 38, + comfortMax: 53, + polyphony: 2, + monophonic: false + }, + 48: { + name: 'String Ensemble 1', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 16, + monophonic: false + }, + 49: { + name: 'String Ensemble 2', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 16, + monophonic: false + }, + 50: { + name: 'Synth Strings 1', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 16, + monophonic: false + }, + 51: { + name: 'Synth Strings 2', + rangeMin: 28, + rangeMax: 100, + comfortMin: 36, + comfortMax: 91, + polyphony: 16, + monophonic: false + }, + 52: { + name: 'Choir Aahs', + rangeMin: 40, + rangeMax: 84, + comfortMin: 48, + comfortMax: 79, + polyphony: 16, + monophonic: false + }, + 53: { + name: 'Voice Oohs', + rangeMin: 40, + rangeMax: 84, + comfortMin: 48, + comfortMax: 79, + polyphony: 16, + monophonic: false + }, + 54: { + name: 'Synth Voice', + rangeMin: 40, + rangeMax: 84, + comfortMin: 48, + comfortMax: 79, + polyphony: 16, + monophonic: false + }, + 55: { + name: 'Orchestra Hit', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 56: { + name: 'Trumpet', + rangeMin: 52, + rangeMax: 84, + comfortMin: 55, + comfortMax: 79, + polyphony: 1, + monophonic: true + }, + 57: { + name: 'Trombone', + rangeMin: 40, + rangeMax: 72, + comfortMin: 43, + comfortMax: 67, + polyphony: 1, + monophonic: true + }, + 58: { + name: 'Tuba', + rangeMin: 28, + rangeMax: 58, + comfortMin: 33, + comfortMax: 55, + polyphony: 1, + monophonic: true + }, + 59: { + name: 'Muted Trumpet', + rangeMin: 52, + rangeMax: 82, + comfortMin: 55, + comfortMax: 77, + polyphony: 1, + monophonic: true + }, + 60: { + name: 'French Horn', + rangeMin: 34, + rangeMax: 77, + comfortMin: 41, + comfortMax: 72, + polyphony: 1, + monophonic: true + }, + 61: { + name: 'Brass Section', + rangeMin: 40, + rangeMax: 84, + comfortMin: 48, + comfortMax: 77, + polyphony: 8, + monophonic: false + }, + 62: { + name: 'Synth Brass 1', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 63: { + name: 'Synth Brass 2', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 64: { + name: 'Soprano Sax', + rangeMin: 56, + rangeMax: 87, + comfortMin: 59, + comfortMax: 84, + polyphony: 1, + monophonic: true + }, + 65: { + name: 'Alto Sax', + rangeMin: 49, + rangeMax: 80, + comfortMin: 52, + comfortMax: 77, + polyphony: 1, + monophonic: true + }, + 66: { + name: 'Tenor Sax', + rangeMin: 44, + rangeMax: 75, + comfortMin: 47, + comfortMax: 72, + polyphony: 1, + monophonic: true + }, + 67: { + name: 'Baritone Sax', + rangeMin: 36, + rangeMax: 68, + comfortMin: 39, + comfortMax: 65, + polyphony: 1, + monophonic: true + }, + 68: { + name: 'Oboe', + rangeMin: 58, + rangeMax: 91, + comfortMin: 60, + comfortMax: 86, + polyphony: 1, + monophonic: true + }, + 69: { + name: 'English Horn', + rangeMin: 52, + rangeMax: 81, + comfortMin: 55, + comfortMax: 77, + polyphony: 1, + monophonic: true + }, + 70: { + name: 'Bassoon', + rangeMin: 34, + rangeMax: 72, + comfortMin: 38, + comfortMax: 67, + polyphony: 1, + monophonic: true + }, + 71: { + name: 'Clarinet', + rangeMin: 50, + rangeMax: 91, + comfortMin: 52, + comfortMax: 86, + polyphony: 1, + monophonic: true + }, + 72: { + name: 'Piccolo', + rangeMin: 74, + rangeMax: 108, + comfortMin: 76, + comfortMax: 103, + polyphony: 1, + monophonic: true + }, + 73: { + name: 'Flute', + rangeMin: 60, + rangeMax: 96, + comfortMin: 62, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 74: { + name: 'Recorder', + rangeMin: 60, + rangeMax: 86, + comfortMin: 62, + comfortMax: 84, + polyphony: 1, + monophonic: true + }, + 75: { + name: 'Pan Flute', + rangeMin: 60, + rangeMax: 84, + comfortMin: 62, + comfortMax: 79, + polyphony: 1, + monophonic: true + }, + 76: { + name: 'Blown Bottle', + rangeMin: 60, + rangeMax: 84, + comfortMin: 62, + comfortMax: 79, + polyphony: 1, + monophonic: true + }, + 77: { + name: 'Shakuhachi', + rangeMin: 55, + rangeMax: 84, + comfortMin: 57, + comfortMax: 79, + polyphony: 1, + monophonic: true + }, + 78: { + name: 'Whistle', + rangeMin: 60, + rangeMax: 96, + comfortMin: 64, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 79: { + name: 'Ocarina', + rangeMin: 60, + rangeMax: 84, + comfortMin: 62, + comfortMax: 79, + polyphony: 1, + monophonic: true + }, + 80: { + name: 'Lead 1 (square)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 81: { + name: 'Lead 2 (sawtooth)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 82: { + name: 'Lead 3 (calliope)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 83: { + name: 'Lead 4 (chiff)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 84: { + name: 'Lead 5 (charang)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 85: { + name: 'Lead 6 (voice)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 1, + monophonic: true + }, + 86: { + name: 'Lead 7 (fifths)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 2, + monophonic: false + }, + 87: { + name: 'Lead 8 (bass + lead)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 48, + comfortMax: 91, + polyphony: 2, + monophonic: false + }, + 88: { + name: 'Pad 1 (new age)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 89: { + name: 'Pad 2 (warm)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 90: { + name: 'Pad 3 (polysynth)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 91: { + name: 'Pad 4 (choir)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 92: { + name: 'Pad 5 (bowed)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 93: { + name: 'Pad 6 (metallic)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 94: { + name: 'Pad 7 (halo)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 95: { + name: 'Pad 8 (sweep)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 8, + monophonic: false + }, + 96: { + name: 'FX 1 (rain)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 97: { + name: 'FX 2 (soundtrack)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 98: { + name: 'FX 3 (crystal)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 99: { + name: 'FX 4 (atmosphere)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 100: { + name: 'FX 5 (brightness)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 101: { + name: 'FX 6 (goblins)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 102: { + name: 'FX 7 (echoes)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 103: { + name: 'FX 8 (sci-fi)', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 104: { + name: 'Sitar', + rangeMin: 48, + rangeMax: 84, + comfortMin: 48, + comfortMax: 79, + polyphony: 4, + monophonic: false + }, + 105: { + name: 'Banjo', + rangeMin: 50, + rangeMax: 86, + comfortMin: 50, + comfortMax: 81, + polyphony: 6, + monophonic: false + }, + 106: { + name: 'Shamisen', + rangeMin: 50, + rangeMax: 84, + comfortMin: 50, + comfortMax: 79, + polyphony: 4, + monophonic: false + }, + 107: { + name: 'Koto', + rangeMin: 43, + rangeMax: 84, + comfortMin: 43, + comfortMax: 79, + polyphony: 4, + monophonic: false + }, + 108: { + name: 'Kalimba', + rangeMin: 53, + rangeMax: 84, + comfortMin: 55, + comfortMax: 79, + polyphony: 4, + monophonic: false + }, + 109: { + name: 'Bagpipe', + rangeMin: 62, + rangeMax: 86, + comfortMin: 62, + comfortMax: 81, + polyphony: 1, + monophonic: true + }, + 110: { + name: 'Fiddle', + rangeMin: 55, + rangeMax: 103, + comfortMin: 55, + comfortMax: 96, + polyphony: 4, + monophonic: false + }, + 111: { + name: 'Shanai', + rangeMin: 62, + rangeMax: 86, + comfortMin: 64, + comfortMax: 81, + polyphony: 1, + monophonic: true + }, + 112: { + name: 'Tinkle Bell', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 113: { + name: 'Agogo', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 114: { + name: 'Steel Drums', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 2, + monophonic: false + }, + 115: { + name: 'Woodblock', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 2, + monophonic: false + }, + 116: { + name: 'Taiko Drum', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 117: { + name: 'Melodic Tom', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 118: { + name: 'Synth Drum', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 119: { + name: 'Reverse Cymbal', + rangeMin: 48, + rangeMax: 96, + comfortMin: 53, + comfortMax: 89, + polyphony: 4, + monophonic: false + }, + 120: { + name: 'Guitar Fret Noise', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 121: { + name: 'Breath Noise', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 122: { + name: 'Seashore', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 123: { + name: 'Bird Tweet', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 124: { + name: 'Telephone Ring', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 125: { + name: 'Helicopter', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 126: { + name: 'Applause', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true + }, + 127: { + name: 'Gunshot', + rangeMin: 36, + rangeMax: 96, + comfortMin: 36, + comfortMax: 96, + polyphony: 1, + monophonic: true } + }; + + /** + * Capability entry for a GM program, or null when out of range / unknown. + * @param {number} gmProgram - GM program number (0-127) + * @returns {?{name:string,rangeMin:number,rangeMax:number,comfortMin:number,comfortMax:number,polyphony:number,monophonic:boolean}} + */ + function get(gmProgram) { + if (!Number.isFinite(gmProgram)) return null; + return CAPABILITIES[gmProgram] || null; + } - const api = { CAPABILITIES: CAPABILITIES, get: get }; + const api = { CAPABILITIES: CAPABILITIES, get: get }; - if (typeof window !== 'undefined') window.GmInstrumentCapabilities = api; - if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (typeof window !== 'undefined') window.GmInstrumentCapabilities = api; + if (typeof module !== 'undefined' && module.exports) module.exports = api; })(); diff --git a/public/js/features/HandPositionWarningsToast.js b/public/js/features/HandPositionWarningsToast.js index 98325ef77..abd5c002e 100644 --- a/public/js/features/HandPositionWarningsToast.js +++ b/public/js/features/HandPositionWarningsToast.js @@ -17,127 +17,124 @@ * so the UX matches the rest of the app. * - i18n-aware: uses `window.i18n.t` with a French fallback. */ -(function() { - 'use strict'; +(function () { + 'use strict'; - const TOAST_DURATION_MS = 5000; - const AGGREGATE_WINDOW_MS = 400; - const BURST_CAP = 20; + const TOAST_DURATION_MS = 5000; + const AGGREGATE_WINDOW_MS = 400; + const BURST_CAP = 20; - function t(key, fallback) { - if (window.i18n && typeof window.i18n.t === 'function') { - const v = window.i18n.t(key); - if (v && v !== key) return v; - } - return fallback; + function t(key, fallback) { + if (window.i18n && typeof window.i18n.t === 'function') { + const v = window.i18n.t(key); + if (v && v !== key) return v; } + return fallback; + } - // Delegate to the shared helper (public/js/utils/escapeHtml.js) so we - // don't drift on edge cases (see AUDIT 2026-05-10 §20). - const escapeHtml = (s) => (window.escapeHtml ? window.escapeHtml(s) : (s == null ? '' : String(s))); + // Delegate to the shared helper (public/js/utils/escapeHtml.js) so we + // don't drift on edge cases (see AUDIT 2026-05-10 §20). + const escapeHtml = (s) => (window.escapeHtml ? window.escapeHtml(s) : s == null ? '' : String(s)); - function summarize(warnings) { - const counts = {}; - for (const w of warnings) { - const code = w?.code || 'unknown'; - counts[code] = (counts[code] || 0) + 1; - } - const ordered = Object.entries(counts).sort((a, b) => b[1] - a[1]); - const labels = { - move_too_fast: t('handPosition.warnMoveTooFast', 'déplacements trop rapides'), - chord_span_exceeded: t('handPosition.warnChordSpan', 'accords trop larges'), - too_many_fingers: t('handPosition.warnTooManyFingers','trop de doigts requis'), - out_of_range: t('handPosition.warnOutOfRange', 'notes hors plage'), - finger_interval_violated: t('handPosition.warnFingerInterval','doigts trop rapprochés') - }; - return ordered - .map(([code, n]) => `${n}× ${labels[code] || code}`) - .join(', '); + function summarize(warnings) { + const counts = {}; + for (const w of warnings) { + const code = w?.code || 'unknown'; + counts[code] = (counts[code] || 0) + 1; } + const ordered = Object.entries(counts).sort((a, b) => b[1] - a[1]); + const labels = { + move_too_fast: t('handPosition.warnMoveTooFast', 'déplacements trop rapides'), + chord_span_exceeded: t('handPosition.warnChordSpan', 'accords trop larges'), + too_many_fingers: t('handPosition.warnTooManyFingers', 'trop de doigts requis'), + out_of_range: t('handPosition.warnOutOfRange', 'notes hors plage'), + finger_interval_violated: t('handPosition.warnFingerInterval', 'doigts trop rapprochés') + }; + return ordered.map(([code, n]) => `${n}× ${labels[code] || code}`).join(', '); + } - function showToast(message) { - const toast = document.createElement('div'); - toast.className = 'hand-position-warnings-toast'; - toast.style.cssText = [ - 'position: fixed', - 'top: 24px', - 'right: 24px', - 'z-index: 10010', - 'padding: 12px 20px', - 'border-radius: 8px', - 'background: #f59e0b', - 'color: white', - 'font-size: 14px', - 'box-shadow: 0 4px 12px rgba(0,0,0,0.2)', - 'display: flex', - 'align-items: center', - 'gap: 8px', - 'max-width: 420px', - 'line-height: 1.35' - ].join(';'); - toast.innerHTML = - ` ${escapeHtml(message)}`; - toast.addEventListener('click', () => toast.remove()); - document.body.appendChild(toast); - setTimeout(() => { - if (!toast.isConnected) return; - toast.style.opacity = '0'; - toast.style.transition = 'opacity 0.3s ease'; - setTimeout(() => toast.remove(), 300); - }, TOAST_DURATION_MS); - } + function showToast(message) { + const toast = document.createElement('div'); + toast.className = 'hand-position-warnings-toast'; + toast.style.cssText = [ + 'position: fixed', + 'top: 24px', + 'right: 24px', + 'z-index: 10010', + 'padding: 12px 20px', + 'border-radius: 8px', + 'background: #f59e0b', + 'color: white', + 'font-size: 14px', + 'box-shadow: 0 4px 12px rgba(0,0,0,0.2)', + 'display: flex', + 'align-items: center', + 'gap: 8px', + 'max-width: 420px', + 'line-height: 1.35' + ].join(';'); + toast.innerHTML = ` ${escapeHtml(message)}`; + toast.addEventListener('click', () => toast.remove()); + document.body.appendChild(toast); + setTimeout(() => { + if (!toast.isConnected) return; + toast.style.opacity = '0'; + toast.style.transition = 'opacity 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, TOAST_DURATION_MS); + } - /** - * Aggregation buffer. Multiple bursts for different fileIds stay - * separate; within one fileId, warnings arriving close in time are - * merged so a 10-warning burst doesn't spam the UI. - */ - function createAggregator() { - const pending = new Map(); // fileId → { warnings, timer } - return function push(ev) { - if (!ev || !Array.isArray(ev.warnings) || ev.warnings.length === 0) return; - const fileId = ev.fileId ?? '_'; - const existing = pending.get(fileId); - const merged = existing ? existing.warnings.concat(ev.warnings) : ev.warnings.slice(); - if (existing) clearTimeout(existing.timer); - const timer = setTimeout(() => { - pending.delete(fileId); - const all = merged.slice(0, BURST_CAP); - const summary = summarize(all); - const header = t('handPosition.toastPrefix', 'Faisabilité main') + ' :'; - showToast(`${header} ${summary}`); - }, AGGREGATE_WINDOW_MS); - pending.set(fileId, { warnings: merged, timer }); - }; - } + /** + * Aggregation buffer. Multiple bursts for different fileIds stay + * separate; within one fileId, warnings arriving close in time are + * merged so a 10-warning burst doesn't spam the UI. + */ + function createAggregator() { + const pending = new Map(); // fileId → { warnings, timer } + return function push(ev) { + if (!ev || !Array.isArray(ev.warnings) || ev.warnings.length === 0) return; + const fileId = ev.fileId ?? '_'; + const existing = pending.get(fileId); + const merged = existing ? existing.warnings.concat(ev.warnings) : ev.warnings.slice(); + if (existing) clearTimeout(existing.timer); + const timer = setTimeout(() => { + pending.delete(fileId); + const all = merged.slice(0, BURST_CAP); + const summary = summarize(all); + const header = t('handPosition.toastPrefix', 'Faisabilité main') + ' :'; + showToast(`${header} ${summary}`); + }, AGGREGATE_WINDOW_MS); + pending.set(fileId, { warnings: merged, timer }); + }; + } - function init() { - if (window.__handPositionWarningsToastInstalled) return; - const api = window.api; - if (!api || typeof api.on !== 'function') return false; - window.__handPositionWarningsToastInstalled = true; - const push = createAggregator(); - api.on('playback_hand_position_warnings', push); - return true; - } + function init() { + if (window.__handPositionWarningsToastInstalled) return; + const api = window.api; + if (!api || typeof api.on !== 'function') return false; + window.__handPositionWarningsToastInstalled = true; + const push = createAggregator(); + api.on('playback_hand_position_warnings', push); + return true; + } - // Try immediately; if the API client is not wired yet, poll until it - // is. We stop polling after 30s to avoid leaking forever on pages - // that never instantiate the API (e.g. static docs). - if (!init()) { - const started = Date.now(); - const iv = setInterval(() => { - if (init() || Date.now() - started > 30000) clearInterval(iv); - }, 250); - } + // Try immediately; if the API client is not wired yet, poll until it + // is. We stop polling after 30s to avoid leaking forever on pages + // that never instantiate the API (e.g. static docs). + if (!init()) { + const started = Date.now(); + const iv = setInterval(() => { + if (init() || Date.now() - started > 30000) clearInterval(iv); + }, 250); + } - // Expose helpers for unit tests and for other modules that want to - // surface warnings without going through the WS event. - if (typeof window !== 'undefined') { - window.HandPositionWarningsToast = { - show: showToast, - summarize, - _createAggregator: createAggregator - }; - } + // Expose helpers for unit tests and for other modules that want to + // surface warnings without going through the WS event. + if (typeof window !== 'undefined') { + window.HandPositionWarningsToast = { + show: showToast, + summarize, + _createAggregator: createAggregator + }; + } })(); diff --git a/public/js/features/InstrumentLoadingToast.js b/public/js/features/InstrumentLoadingToast.js index 1946b8159..8afed1709 100644 --- a/public/js/features/InstrumentLoadingToast.js +++ b/public/js/features/InstrumentLoadingToast.js @@ -23,95 +23,95 @@ * (slate-grey vs amber warning) so the user reads it as an informational * progress indicator rather than a problem. */ -(function() { - 'use strict'; +(function () { + 'use strict'; - let refCount = 0; - let toastEl = null; + let refCount = 0; + let toastEl = null; - function t(key, fallback) { - if (window.i18n && typeof window.i18n.t === 'function') { - const v = window.i18n.t(key); - if (v && v !== key) return v; - } - return fallback; + function t(key, fallback) { + if (window.i18n && typeof window.i18n.t === 'function') { + const v = window.i18n.t(key); + if (v && v !== key) return v; } + return fallback; + } - function escapeHtml(s) { - return window.escapeHtml ? window.escapeHtml(s) - : (s == null ? '' : String(s)); - } + function escapeHtml(s) { + return window.escapeHtml ? window.escapeHtml(s) : s == null ? '' : String(s); + } - function render(message) { - if (toastEl) { - // Just update the text — keep the element so the user doesn't - // see a flicker when subsequent loads bump the message. - const span = toastEl.querySelector('.ilt-msg'); - if (span) span.textContent = message; - return; - } - toastEl = document.createElement('div'); - toastEl.className = 'instrument-loading-toast'; - toastEl.setAttribute('role', 'status'); - toastEl.setAttribute('aria-live', 'polite'); - toastEl.style.cssText = [ - 'position: fixed', - 'bottom: 24px', - 'right: 24px', - 'z-index: 10010', - 'padding: 10px 16px', - 'border-radius: 8px', - 'background: #334155', // slate-700 - 'color: #f1f5f9', // slate-100 - 'font-size: 13px', - 'box-shadow: 0 4px 12px rgba(0,0,0,0.25)', - 'display: flex', - 'align-items: center', - 'gap: 10px', - 'max-width: 360px', - 'line-height: 1.35', - 'pointer-events: none' // never blocks clicks on the UI - ].join(';'); - // CSS spinner using inline keyframes so the module is self-contained - // and works on pages that don't import a separate stylesheet. - const styleId = 'instrument-loading-toast-style'; - if (!document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - style.textContent = - '@keyframes ilt-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}'; - document.head.appendChild(style); - } - toastEl.innerHTML = - '' - + `${escapeHtml(message)}`; - document.body.appendChild(toastEl); + function render(message) { + if (toastEl) { + // Just update the text — keep the element so the user doesn't + // see a flicker when subsequent loads bump the message. + const span = toastEl.querySelector('.ilt-msg'); + if (span) span.textContent = message; + return; } - - function remove() { - if (!toastEl) return; - // Brief fade so it doesn't pop out abruptly on fast follow-up loads. - toastEl.style.transition = 'opacity 0.2s ease'; - toastEl.style.opacity = '0'; - const el = toastEl; - toastEl = null; - setTimeout(() => el.remove(), 220); + toastEl = document.createElement('div'); + toastEl.className = 'instrument-loading-toast'; + toastEl.setAttribute('role', 'status'); + toastEl.setAttribute('aria-live', 'polite'); + toastEl.style.cssText = [ + 'position: fixed', + 'bottom: 24px', + 'right: 24px', + 'z-index: 10010', + 'padding: 10px 16px', + 'border-radius: 8px', + 'background: #334155', // slate-700 + 'color: #f1f5f9', // slate-100 + 'font-size: 13px', + 'box-shadow: 0 4px 12px rgba(0,0,0,0.25)', + 'display: flex', + 'align-items: center', + 'gap: 10px', + 'max-width: 360px', + 'line-height: 1.35', + 'pointer-events: none' // never blocks clicks on the UI + ].join(';'); + // CSS spinner using inline keyframes so the module is self-contained + // and works on pages that don't import a separate stylesheet. + const styleId = 'instrument-loading-toast-style'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = + '@keyframes ilt-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}'; + document.head.appendChild(style); } + toastEl.innerHTML = + '' + + `${escapeHtml(message)}`; + document.body.appendChild(toastEl); + } - window.InstrumentLoadingToast = { - show(message) { - refCount++; - const msg = message - || t('instrumentLoading.message', 'Loading instrument samples…'); - render(msg); - }, - hide() { - if (refCount <= 0) return; - refCount--; - if (refCount === 0) remove(); - }, - _getCount() { return refCount; } - }; + function remove() { + if (!toastEl) return; + // Brief fade so it doesn't pop out abruptly on fast follow-up loads. + toastEl.style.transition = 'opacity 0.2s ease'; + toastEl.style.opacity = '0'; + const el = toastEl; + toastEl = null; + setTimeout(() => el.remove(), 220); + } + + window.InstrumentLoadingToast = { + show(message) { + refCount++; + const msg = message || t('instrumentLoading.message', 'Loading instrument samples…'); + render(msg); + }, + hide() { + if (refCount <= 0) return; + refCount--; + if (refCount === 0) remove(); + }, + _getCount() { + return refCount; + } + }; })(); diff --git a/public/js/features/InstrumentManagementPage.js b/public/js/features/InstrumentManagementPage.js index ee5e9a730..bf8fbbae7 100644 --- a/public/js/features/InstrumentManagementPage.js +++ b/public/js/features/InstrumentManagementPage.js @@ -160,8 +160,8 @@ class InstrumentManagementPage { try { // 1. Load the connected devices (enriched with multi-channel instruments[]) const response = await this.apiClient.sendCommand('device_list', {}); - const connectedDevices = (response && response.devices) ? response.devices : []; - const connectedIds = new Set(connectedDevices.map(d => d.id)); + const connectedDevices = response && response.devices ? response.devices : []; + const connectedIds = new Set(connectedDevices.map((d) => d.id)); // 2. Load instruments saved in the DB (even if disconnected) let registeredInstruments = []; @@ -187,7 +187,7 @@ class InstrumentManagementPage { _deviceName: device.name, _deviceDisplayName: device.deviceCustomName || null, _deviceType: device.type, - _deviceAddress: device.address, + _deviceAddress: device.address }; // If the device has multi-channel instruments, create one entry per channel @@ -196,7 +196,9 @@ class InstrumentManagementPage { const dbId = inst.id || `${device.id}_${inst.channel}`; matchedDbIds.add(dbId); // Also mark it in registeredInstruments - const regMatch = registeredInstruments.find(r => r.id === dbId || (r.device_id === device.id && r.channel === inst.channel)); + const regMatch = registeredInstruments.find( + (r) => r.id === dbId || (r.device_id === device.id && r.channel === inst.channel) + ); if (regMatch) matchedDbIds.add(regMatch.id); this.instruments.push({ @@ -211,31 +213,33 @@ class InstrumentManagementPage { output: device.output, usb_serial_number: device.usb_serial_number || device.usbSerialNumber, displayName: inst.custom_name || inst.name || device.displayName || device.name, - channel: inst.channel !== undefined ? inst.channel : 0, + channel: inst.channel !== undefined ? inst.channel : 0 }); } } else { // Device without multi-channel instruments: search in DB (legacy behavior) - let dbInstrument = registeredInstruments.find(r => r.device_id === device.id); + let dbInstrument = registeredInstruments.find((r) => r.device_id === device.id); // Fallback: search by USB serial number if not found by device_id if (!dbInstrument && (device.usb_serial_number || device.usbSerialNumber)) { const serial = device.usb_serial_number || device.usbSerialNumber; - dbInstrument = registeredInstruments.find(r => r.usb_serial_number === serial); + dbInstrument = registeredInstruments.find((r) => r.usb_serial_number === serial); } // Fallback: search by MAC address for Bluetooth devices if (!dbInstrument && device.address && device.type === 'bluetooth') { - dbInstrument = registeredInstruments.find(r => r.mac_address === device.address); + dbInstrument = registeredInstruments.find((r) => r.mac_address === device.address); } // Fallback: search by normalized name (without ALSA port numbers) if (!dbInstrument && device.id) { const normalizedDeviceName = InstrumentManagementPage.normalizeDeviceName(device.id); if (normalizedDeviceName && normalizedDeviceName !== 'virtual') { - dbInstrument = registeredInstruments.find(r => { + dbInstrument = registeredInstruments.find((r) => { const normalizedDbName = InstrumentManagementPage.normalizeDeviceName(r.device_id); - return normalizedDbName === normalizedDeviceName && !r.device_id.startsWith('virtual_'); + return ( + normalizedDbName === normalizedDeviceName && !r.device_id.startsWith('virtual_') + ); }); } } @@ -282,14 +286,16 @@ class InstrumentManagementPage { const deviceCustomNames = new Map(); const disconnectedDeviceIds = new Set( registeredInstruments - .filter(r => !matchedDbIds.has(r.id) && !connectedIds.has(r.device_id)) - .map(r => r.device_id) + .filter((r) => !matchedDbIds.has(r.id) && !connectedIds.has(r.device_id)) + .map((r) => r.device_id) ); for (const did of disconnectedDeviceIds) { try { const resp = await this.apiClient.sendCommand('device_get_settings', { deviceId: did }); if (resp?.settings?.custom_name) deviceCustomNames.set(did, resp.settings.custom_name); - } catch (_e) { /* ignore */ } + } catch (_e) { + /* ignore */ + } } for (const registered of registeredInstruments) { @@ -303,11 +309,22 @@ class InstrumentManagementPage { if (registered.usb_serial_number && seenSerials.has(registered.usb_serial_number)) continue; // Deduplicate: if an instrument with the same MAC is already shown - if (registered.mac_address && connectedDevices.some(d => d.address === registered.mac_address)) continue; + if ( + registered.mac_address && + connectedDevices.some((d) => d.address === registered.mac_address) + ) + continue; // Deduplicate: if a connected device has the same normalized name - const normalizedRegName = InstrumentManagementPage.normalizeDeviceName(registered.device_id); - if (normalizedRegName && !registered.device_id.startsWith('virtual_') && seenNormalizedNames.has(normalizedRegName)) continue; + const normalizedRegName = InstrumentManagementPage.normalizeDeviceName( + registered.device_id + ); + if ( + normalizedRegName && + !registered.device_id.startsWith('virtual_') && + seenNormalizedNames.has(normalizedRegName) + ) + continue; registered.id = registered.device_id; registered._deviceId = registered.device_id; @@ -324,7 +341,7 @@ class InstrumentManagementPage { // Filter out virtual instruments if disabled in settings if (!this._isVirtualEnabled()) { - this.instruments = this.instruments.filter(inst => !this.isVirtualInstrument(inst)); + this.instruments = this.instruments.filter((inst) => !this.isVirtualInstrument(inst)); } // Mark virtual instruments as always available @@ -364,7 +381,9 @@ class InstrumentManagementPage { if (saved) { return !!JSON.parse(saved).virtualInstrument; } - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } return false; } @@ -378,7 +397,9 @@ class InstrumentManagementPage { const parsed = saved ? JSON.parse(saved) : {}; parsed.virtualInstrument = !!enabled; localStorage.setItem('gmboop_settings', JSON.stringify(parsed)); - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } // Update the slider visuals without a full re-render const slider = this.modal && this.modal.querySelector('.inst-mgmt-virt-slider'); @@ -429,22 +450,25 @@ class InstrumentManagementPage { // Search if (this.searchQuery) { const query = this.searchQuery.toLowerCase(); - filtered = filtered.filter(inst => - (inst.name || '').toLowerCase().includes(query) || - (inst.custom_name || '').toLowerCase().includes(query) || - (inst.manufacturer || '').toLowerCase().includes(query) + filtered = filtered.filter( + (inst) => + (inst.name || '').toLowerCase().includes(query) || + (inst.custom_name || '').toLowerCase().includes(query) || + (inst.manufacturer || '').toLowerCase().includes(query) ); } // Filter by status if (this.filterStatus === 'complete') { - filtered = filtered.filter(inst => this.isInstrumentComplete(inst)); + filtered = filtered.filter((inst) => this.isInstrumentComplete(inst)); } else if (this.filterStatus === 'incomplete') { - filtered = filtered.filter(inst => !this.isInstrumentComplete(inst)); + filtered = filtered.filter((inst) => !this.isInstrumentComplete(inst)); } else if (this.filterStatus === 'connected') { - filtered = filtered.filter(inst => (inst.status === 2 || inst.connected) && !this.isVirtualInstrument(inst)); + filtered = filtered.filter( + (inst) => (inst.status === 2 || inst.connected) && !this.isVirtualInstrument(inst) + ); } else if (this.filterStatus === 'virtual') { - filtered = filtered.filter(inst => this.isVirtualInstrument(inst)); + filtered = filtered.filter((inst) => this.isVirtualInstrument(inst)); } if (filtered.length === 0) { @@ -453,9 +477,13 @@ class InstrumentManagementPage {
🎹

${i18n.t('instrumentManagement.noInstruments') || 'Aucun instrument trouvé'}

- ${this.searchQuery || this.filterStatus !== 'all' - ? (i18n.t('instrumentManagement.adjustFilter') || 'Essayez de modifier votre recherche ou filtre') - : (i18n.t('instrumentManagement.scanToStart') || 'Scannez vos périphériques pour commencer')} + ${ + this.searchQuery || this.filterStatus !== 'all' + ? i18n.t('instrumentManagement.adjustFilter') || + 'Essayez de modifier votre recherche ou filtre' + : i18n.t('instrumentManagement.scanToStart') || + 'Scannez vos périphériques pour commencer' + }

`; @@ -504,7 +532,7 @@ class InstrumentManagementPage { ${totalInst}
- ${connectedGroups.map(g => this.renderDeviceBlock(g.instruments)).join('')} + ${connectedGroups.map((g) => this.renderDeviceBlock(g.instruments)).join('')}
`; @@ -521,7 +549,7 @@ class InstrumentManagementPage { ${totalInst}
- ${virtualGroups.map(g => this.renderDeviceBlock(g.instruments)).join('')} + ${virtualGroups.map((g) => this.renderDeviceBlock(g.instruments)).join('')}
`; @@ -538,7 +566,7 @@ class InstrumentManagementPage { ${totalInst}
- ${disconnectedGroups.map(g => this.renderDeviceBlock(g.instruments)).join('')} + ${disconnectedGroups.map((g) => this.renderDeviceBlock(g.instruments)).join('')}
`; @@ -573,18 +601,22 @@ class InstrumentManagementPage { const isConnected = first.status === 2 || first.connected; const isVirtual = this.isVirtualInstrument(first); const connType = this.getConnectionTypeInfo(first); - const borderColor = isVirtual ? '#8b5cf6' : (isConnected ? '#10b981' : '#e5e7eb'); + const borderColor = isVirtual ? '#8b5cf6' : isConnected ? '#10b981' : '#e5e7eb'; const headerBg = isVirtual ? 'linear-gradient(135deg, rgba(139,92,246,0.1), rgba(139,92,246,0.05))' - : (isConnected + : isConnected ? 'linear-gradient(135deg, rgba(16,185,129,0.08), rgba(16,185,129,0.04))' - : 'rgba(0,0,0,0.02)'); - const headerBorder = isVirtual ? '1px solid rgba(139,92,246,0.2)' : (isConnected ? '1px solid rgba(16,185,129,0.2)' : '1px solid #e5e7eb'); + : 'rgba(0,0,0,0.02)'; + const headerBorder = isVirtual + ? '1px solid rgba(139,92,246,0.2)' + : isConnected + ? '1px solid rgba(16,185,129,0.2)' + : '1px solid #e5e7eb'; const statusDot = isVirtual ? `🎛️` - : (isConnected + : isConnected ? `` - : ``); + : ``; return `
⚙️ + title="${typeof i18n !== 'undefined' ? i18n.t('instruments.deviceSettings') || 'Réglages du périphérique' : 'Réglages du périphérique'}">⚙️
- ${instruments.map(inst => this.renderInstrumentSubCard(inst)).join('')} + ${instruments.map((inst) => this.renderInstrumentSubCard(inst)).join('')}
`; @@ -640,12 +672,13 @@ class InstrumentManagementPage { // resolver can look up the matching `drum_kit_.svg`. const gmProgram = instrument.gm_program; const isDrumChannel = channel === 9; - const offset = (typeof GM_DRUM_KIT_OFFSET !== 'undefined') ? GM_DRUM_KIT_OFFSET : 128; - const resolverProgram = (isDrumChannel && gmProgram != null && gmProgram < offset) - ? (gmProgram + offset) : gmProgram; - const icon = (window.InstrumentFamilies && window.InstrumentFamilies.resolveInstrumentIcon) - ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram: resolverProgram, channel }) - : { svgUrl: null, emoji: '🎵', slug: null }; + const offset = typeof GM_DRUM_KIT_OFFSET !== 'undefined' ? GM_DRUM_KIT_OFFSET : 128; + const resolverProgram = + isDrumChannel && gmProgram != null && gmProgram < offset ? gmProgram + offset : gmProgram; + const icon = + window.InstrumentFamilies && window.InstrumentFamilies.resolveInstrumentIcon + ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram: resolverProgram, channel }) + : { svgUrl: null, emoji: '🎵', slug: null }; const iconHtml = icon.slug ? `= 2 ? '🙌' : (handsCount === 1 ? '🫱' : ''); - const handsBadgeHtml = handsEnabled && handsEmoji - ? `${handsEmoji}` - : ''; + const handsEmoji = handsCount >= 2 ? '🙌' : handsCount === 1 ? '🫱' : ''; + const handsBadgeHtml = + handsEnabled && handsEmoji + ? `${handsEmoji}` + : ''; // Lighting badge: 💡 when the instrument's firmware advertises any // of the CC 110-114 lighting controls (`lighting_enabled` in // `instruments_latency`). - const lightingBadgeHtml = (instrument.lighting_enabled === true || instrument.lighting_enabled === 1) - ? `💡` - : ''; + const lightingBadgeHtml = + instrument.lighting_enabled === true || instrument.lighting_enabled === 1 + ? `💡` + : ''; const cardBg = `rgba(${this._hexToRgb(channelColor)}, 0.06)`; const cardBorder = `1px solid rgba(${this._hexToRgb(channelColor)}, 0.2)`; @@ -718,23 +757,31 @@ class InstrumentManagementPage {
- ${gmProgram !== null && gmProgram !== undefined - ? `${esc(displayName)}` - : `${i18n.t('instrumentManagement.gmProgramNotSet') || 'Programme GM non défini'}`} + ${ + gmProgram !== null && gmProgram !== undefined + ? `${esc(displayName)}` + : `${i18n.t('instrumentManagement.gmProgramNotSet') || 'Programme GM non défini'}` + } ${handsBadgeHtml} ${lightingBadgeHtml} - ${isComplete - ? `` - : ``} + ${ + isComplete + ? `` + : `` + }
Ch ${channel + 1} - ${instrument.note_range_min != null && instrument.note_range_max != null - ? `🎹 ${this.getNoteName(instrument.note_range_min)}-${this.getNoteName(instrument.note_range_max)}` - : ((instrument.note_selection_mode === 'discrete' && Array.isArray(instrument.selected_notes) && instrument.selected_notes.length > 0) - ? `🥁 ${instrument.selected_notes.length} notes` - : '')} + ${ + instrument.note_range_min != null && instrument.note_range_max != null + ? `🎹 ${this.getNoteName(instrument.note_range_min)}-${this.getNoteName(instrument.note_range_max)}` + : instrument.note_selection_mode === 'discrete' && + Array.isArray(instrument.selected_notes) && + instrument.selected_notes.length > 0 + ? `🥁 ${instrument.selected_notes.length} notes` + : '' + } ${instrument.polyphony ? `poly: ${instrument.polyphony}` : ''} ${handsEnabled ? `${handsEmoji} ${handsCount} ${handsCount > 1 ? 'mains' : 'main'}` : ''} @@ -759,12 +806,18 @@ class InstrumentManagementPage { getConnectionTypeInfo(instrument) { const type = instrument.type || ''; switch (type) { - case 'bluetooth': return { icon: '📡', label: 'Bluetooth' }; - case 'network': return { icon: '🌐', label: 'WiFi/Réseau' }; - case 'serial': return { icon: '🔌', label: 'Série/GPIO' }; - case 'usb': return { icon: '🔌', label: 'USB' }; - case 'virtual': return { icon: '🖥️', label: 'Virtuel' }; - default: return { icon: '🎹', label: type || 'Inconnu' }; + case 'bluetooth': + return { icon: '📡', label: 'Bluetooth' }; + case 'network': + return { icon: '🌐', label: 'WiFi/Réseau' }; + case 'serial': + return { icon: '🔌', label: 'Série/GPIO' }; + case 'usb': + return { icon: '🔌', label: 'USB' }; + case 'virtual': + return { icon: '🖥️', label: 'Virtuel' }; + default: + return { icon: '🎹', label: type || 'Inconnu' }; } } @@ -773,10 +826,22 @@ class InstrumentManagementPage { */ getChannelColor(channel) { const colors = [ - '#3b82f6', '#ef4444', '#10b981', '#f59e0b', - '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16', - '#f97316', '#6366f1', '#14b8a6', '#e11d48', - '#a855f7', '#0ea5e9', '#22c55e', '#eab308' + '#3b82f6', + '#ef4444', + '#10b981', + '#f59e0b', + '#8b5cf6', + '#ec4899', + '#06b6d4', + '#84cc16', + '#f97316', + '#6366f1', + '#14b8a6', + '#e11d48', + '#a855f7', + '#0ea5e9', + '#22c55e', + '#eab308' ]; return colors[channel % colors.length]; } @@ -803,9 +868,11 @@ class InstrumentManagementPage { // For discrete mode, selected_notes defines the playable notes (no range needed) const isDiscrete = (instrument.note_selection_mode || instrument.mode) === 'discrete'; const hasNotes = isDiscrete - ? (Array.isArray(instrument.selected_notes) && instrument.selected_notes.length > 0) - : (instrument.note_range_min !== null && instrument.note_range_min !== undefined && - instrument.note_range_max !== null && instrument.note_range_max !== undefined); + ? Array.isArray(instrument.selected_notes) && instrument.selected_notes.length > 0 + : instrument.note_range_min !== null && + instrument.note_range_min !== undefined && + instrument.note_range_max !== null && + instrument.note_range_max !== undefined; return hasGm && hasNotes && hasPolyphony && hasMode; } @@ -828,8 +895,8 @@ class InstrumentManagementPage { if (!statsElement) return; const total = this.instruments.length; - const connected = this.instruments.filter(inst => inst.status === 2 || inst.connected).length; - const complete = this.instruments.filter(inst => this.isInstrumentComplete(inst)).length; + const connected = this.instruments.filter((inst) => inst.status === 2 || inst.connected).length; + const complete = this.instruments.filter((inst) => this.isInstrumentComplete(inst)).length; const incomplete = total - complete; statsElement.innerHTML = ` @@ -838,10 +905,14 @@ class InstrumentManagementPage { ${connected} ${i18n.t('instrumentManagement.connectedCount') || 'connectés'} ${complete} ${i18n.t('instrumentManagement.completeCount') || 'complets'} - ${incomplete > 0 ? ` + ${ + incomplete > 0 + ? ` ${incomplete} ${i18n.t('instrumentManagement.incompleteCount') || 'incomplets'} - ` : ''} + ` + : '' + } `; } @@ -882,8 +953,8 @@ class InstrumentManagementPage { editInstrument(deviceId, channel) { // Use the existing showInstrumentSettings modal - const instrument = this.instruments.find(inst => - inst.id === deviceId && (channel === undefined || inst.channel === channel) + const instrument = this.instruments.find( + (inst) => inst.id === deviceId && (channel === undefined || inst.channel === channel) ); if (instrument && window.showInstrumentSettings) { // Make sure the channel is set for showInstrumentSettings @@ -892,7 +963,11 @@ class InstrumentManagementPage { } window.showInstrumentSettings(instrument); } else { - this.showToast(i18n.t('instrumentManagement.settingsNotAvailable') || 'Réglages non disponibles. Vérifiez que le module est chargé.', 'error'); + this.showToast( + i18n.t('instrumentManagement.settingsNotAvailable') || + 'Réglages non disponibles. Vérifiez que le module est chargé.', + 'error' + ); } } @@ -900,7 +975,7 @@ class InstrumentManagementPage { * Complete an instrument via InstrumentCapabilitiesModal or settings */ async completeInstrument(deviceId) { - const instrument = this.instruments.find(inst => inst.id === deviceId); + const instrument = this.instruments.find((inst) => inst.id === deviceId); if (!instrument) return; try { @@ -909,9 +984,10 @@ class InstrumentManagementPage { if (response && response.incompleteInstruments) { const incomplete = response.incompleteInstruments.find( - item => item.instrument.device_id === deviceId || - item.instrument.id === deviceId || - item.instrument.id === instrument.instrumentId + (item) => + item.instrument.device_id === deviceId || + item.instrument.id === deviceId || + item.instrument.id === instrument.instrumentId ); if (incomplete && window.InstrumentCapabilitiesModal) { @@ -937,8 +1013,8 @@ class InstrumentManagementPage { */ async testInstrument(deviceId, channel) { try { - const instrument = this.instruments.find(inst => - inst.id === deviceId && (channel === undefined || inst.channel === channel) + const instrument = this.instruments.find( + (inst) => inst.id === deviceId && (channel === undefined || inst.channel === channel) ); // Use provided channel, or instrument's channel, default to 0 @@ -949,13 +1025,20 @@ class InstrumentManagementPage { // Pick a test note within the instrument's capabilities let testNote = 60; // Default C4 if (instrument) { - if (instrument.note_selection_mode === 'discrete' && instrument.selected_notes && instrument.selected_notes.length > 0) { + if ( + instrument.note_selection_mode === 'discrete' && + instrument.selected_notes && + instrument.selected_notes.length > 0 + ) { // For discrete mode, pick the first available note testNote = instrument.selected_notes[0]; } else if (instrument.note_range_min !== undefined && instrument.note_range_min !== null) { // For range mode, ensure C4 is within range, otherwise pick middle of range const min = instrument.note_range_min; - const max = instrument.note_range_max !== undefined && instrument.note_range_max !== null ? instrument.note_range_max : 127; + const max = + instrument.note_range_max !== undefined && instrument.note_range_max !== null + ? instrument.note_range_max + : 127; if (testNote < min || testNote > max) { testNote = Math.round((min + max) / 2); } @@ -970,9 +1053,17 @@ class InstrumentManagementPage { duration: 500 }); - this.showToast(i18n.t('instrumentManagement.testNoteSent') || 'Note de test envoyée ! (C4 - Do central)', 'success'); + this.showToast( + i18n.t('instrumentManagement.testNoteSent') || 'Note de test envoyée ! (C4 - Do central)', + 'success' + ); } catch (error) { - this.showToast((i18n.t('instrumentManagement.testNoteFailed') || 'Échec de l\'envoi de la note de test') + ': ' + error.message, 'error'); + this.showToast( + (i18n.t('instrumentManagement.testNoteFailed') || "Échec de l'envoi de la note de test") + + ': ' + + error.message, + 'error' + ); } } @@ -981,9 +1072,10 @@ class InstrumentManagementPage { */ async deleteInstrument(deviceId, channel) { const confirmed = await window.showConfirm( - i18n.t('instrumentManagement.deleteConfirm') || 'Êtes-vous sûr de vouloir supprimer cet instrument de la base de données ?\n\nNote : Le périphérique physique ne sera pas affecté.', + i18n.t('instrumentManagement.deleteConfirm') || + 'Êtes-vous sûr de vouloir supprimer cet instrument de la base de données ?\n\nNote : Le périphérique physique ne sera pas affecté.', { - title: i18n.t('instrumentManagement.deleteTitle') || 'Supprimer l\'instrument', + title: i18n.t('instrumentManagement.deleteTitle') || "Supprimer l'instrument", icon: '🗑️', okText: i18n.t('common.delete') || 'Supprimer', danger: true @@ -999,10 +1091,18 @@ class InstrumentManagementPage { deleteData.channel = channel; } await this.apiClient.sendCommand('instrument_delete', deleteData); - this.showToast(i18n.t('instrumentManagement.deleteSuccess') || 'Instrument supprimé avec succès', 'success'); + this.showToast( + i18n.t('instrumentManagement.deleteSuccess') || 'Instrument supprimé avec succès', + 'success' + ); await this.refresh(); } catch (error) { - this.showToast((i18n.t('instrumentManagement.deleteFailed') || 'Échec de la suppression') + ': ' + error.message, 'error'); + this.showToast( + (i18n.t('instrumentManagement.deleteFailed') || 'Échec de la suppression') + + ': ' + + error.message, + 'error' + ); } } @@ -1011,7 +1111,12 @@ class InstrumentManagementPage { */ static VIRTUAL_PRESETS = [ { type: 'piano', icon: '🎹', label: 'Piano', description: 'A0-C8, polyphonie 64' }, - { type: 'electric_piano', icon: '🎹', label: 'Piano Électrique', description: 'E1-G7, polyphonie 32' }, + { + type: 'electric_piano', + icon: '🎹', + label: 'Piano Électrique', + description: 'E1-G7, polyphonie 32' + }, { type: 'organ', icon: '🎵', label: 'Orgue', description: 'C2-C7, polyphonie 16' }, { type: 'guitar', icon: '🎸', label: 'Guitare', description: 'E2-E6, polyphonie 6' }, { type: 'bass', icon: '🎸', label: 'Basse', description: 'E1-G4, polyphonie 4' }, @@ -1036,7 +1141,8 @@ class InstrumentManagementPage { // Create the selection dialog const overlay = document.createElement('div'); - overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10100;display:flex;align-items:center;justify-content:center;'; + overlay.style.cssText = + 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10100;display:flex;align-items:center;justify-content:center;'; overlay.innerHTML = `
@@ -1053,9 +1159,11 @@ class InstrumentManagementPage { onfocus="this.style.borderColor='#8b5cf6'" onblur="this.style.borderColor='#e5e7eb'">
-

${esc(i18n.t('instrumentManagement.selectType') || 'Sélectionnez un type d\'instrument :')}

+

${esc(i18n.t('instrumentManagement.selectType') || "Sélectionnez un type d'instrument :")}

- ${presets.map(p => ` + ${presets + .map( + (p) => `
- `).join('')} + ` + ) + .join('')}
@@ -1080,7 +1190,7 @@ class InstrumentManagementPage { }); // Handle a preset click - overlay.querySelectorAll('.virtual-preset-btn').forEach(btn => { + overlay.querySelectorAll('.virtual-preset-btn').forEach((btn) => { btn.addEventListener('click', async () => { const type = btn.dataset.type || null; const nameInput = overlay.querySelector('#virtualInstrumentName'); @@ -1111,8 +1221,8 @@ class InstrumentManagementPage { // Open settings for custom instruments (no type) if (!type && response.deviceId && window.showInstrumentSettings) { - const newInstrument = this.instruments.find(i => - i.device_id === response.deviceId || i.id === response.deviceId + const newInstrument = this.instruments.find( + (i) => i.device_id === response.deviceId || i.id === response.deviceId ); if (newInstrument) { window.showInstrumentSettings(newInstrument); @@ -1120,7 +1230,9 @@ class InstrumentManagementPage { } } catch (error) { this.showToast( - (i18n.t('instrumentManagement.virtualCreateFailed') || 'Erreur de création') + ': ' + error.message, + (i18n.t('instrumentManagement.virtualCreateFailed') || 'Erreur de création') + + ': ' + + error.message, 'error' ); } @@ -1135,7 +1247,10 @@ class InstrumentManagementPage { this.showToast(i18n.t('instrumentManagement.scanStarted') || 'Scan USB lancé...', 'success'); setTimeout(() => this.refresh(), 1000); } catch (error) { - this.showToast((i18n.t('instrumentManagement.scanFailed') || 'Échec du scan') + ': ' + error.message, 'error'); + this.showToast( + (i18n.t('instrumentManagement.scanFailed') || 'Échec du scan') + ': ' + error.message, + 'error' + ); } } @@ -1147,7 +1262,10 @@ class InstrumentManagementPage { if (window.showBluetoothScan) { window.showBluetoothScan(); } else { - this.showToast(i18n.t('instrumentManagement.bluetoothNotAvailable') || 'Scan Bluetooth non disponible', 'error'); + this.showToast( + i18n.t('instrumentManagement.bluetoothNotAvailable') || 'Scan Bluetooth non disponible', + 'error' + ); } } @@ -1159,7 +1277,10 @@ class InstrumentManagementPage { if (window.showNetworkScan) { window.showNetworkScan(); } else { - this.showToast(i18n.t('instrumentManagement.networkNotAvailable') || 'Scan réseau non disponible', 'error'); + this.showToast( + i18n.t('instrumentManagement.networkNotAvailable') || 'Scan réseau non disponible', + 'error' + ); } } diff --git a/public/js/features/KeyboardModal.js b/public/js/features/KeyboardModal.js index ff268826c..02ac5786f 100644 --- a/public/js/features/KeyboardModal.js +++ b/public/js/features/KeyboardModal.js @@ -4,1621 +4,1777 @@ // ============================================================================ class KeyboardModal { - constructor(logger = null, eventBus = null) { - this.backend = window.api; - this.logger = logger || console; - this.eventBus = eventBus || window.eventBus || null; - this.isOpen = false; - - // i18n support - this.localeUnsubscribe = null; - - // State - this.devices = []; - this.selectedDevice = null; - this.selectedDeviceCapabilities = null; // Selected instrument capabilities - this.activeNotes = new Set(); - this.mouseActiveNotes = new Set(); // Notes triggered by the mouse (for cleanup on global mouseup) - // In fretboard mode, tracks which specific string:fret positions are pressed - // (same MIDI note can exist on several strings — only the pressed one should highlight). - this.activeFretPositions = new Set(); - this.velocity = 80; - this.modulation = 64; // CC#1 modulation wheel value (center) - this._modWheelDragging = false; - // List view controls - this.listViewYCC = null; // null = vélocité, number = CC# pour l'axe Y - this.listViewPitchBendEnabled = true; // pitch bend actif sur le drag horizontal (X) - this.keyboardLayout = 'azerty'; - this.isMouseDown = false; // For dragging on the keyboard - - // Piano config - this.octaves = 3; // 3 octaves by default (range: 1-8 octaves) - this.minOctaves = 1; - this.maxOctaves = 8; - // Internal: number of visible notes (not always a multiple of 12 once - // the user zooms by 4-note increments). - this.visibleNoteCount = this.octaves * 12; - this.minVisibleNotes = 12; - this.maxVisibleNotes = 96; - this.zoomStep = 4; // semitones added/removed per zoom click/wheel tick - this.startNote = 48; // First MIDI note displayed (C3 by default) - this.defaultStartNote = 48; // Default value for reset - // White notes: relative semitones within an octave - this.whiteNoteOffsets = [0, 2, 4, 5, 7, 9, 11]; // C D E F G A B - // Semitones that have a black key (sharp) - this.blackNoteSemitones = new Set([1, 3, 6, 8, 10]); // C# D# F# G# A# - // Mapping tables for PC keys (generated dynamically) - this.visibleWhiteNotes = []; - this.visibleBlackNotes = []; - - // Note label format: 'english' (C/D/E), 'solfege' (Do/Ré/Mi), 'midi' (60) - this.noteLabelFormat = 'english'; - // View mode: 'piano' (default), 'fretboard' (string instr.), 'drumpad' (drum), 'piano-slider' (wind) - this.viewMode = 'piano'; - // Wind instrument state (set when a wind GM instrument is selected) - this.windPreset = null; - this.currentArticulation = 'normal'; - // Fretboard: show chromatic note colors (12 colors, one per semitone) - this.showNoteColors = false; - // String instrument config (loaded when fretboard mode is enabled) - this.stringInstrumentConfig = null; - // Harp string config (loaded for the dedicated HarpView; kept - // separate from stringInstrumentConfig so the InstrumentDetector - // escape hatch — stringCfg forces fretboard — stays untouched). - this._harpStringConfig = null; - // Per-string pitch bend slide mode active - this._stringSlideActive = false; - // Minimap drag state - this._minimapDragging = false; - - // The PC keyboard mapping is dynamic (see _resolveKeyToNote) - - // Bind handlers - this.handleKeyDown = this.handleKeyDown.bind(this); - this.handleKeyUp = this.handleKeyUp.bind(this); - this.handleGlobalMouseUp = this.handleGlobalMouseUp.bind(this); - - this.container = null; - - // Panel mode (embedded in a host container) - this._panelMode = false; - this._panelCallbacks = null; - this._panelDialogEl = null; - - // InstrumentView migration (Phase C/D complete): the registry is the - // authoritative owner of the active view's lifecycle. setViewMode() - // resolves a registered InstrumentView and drives mount()/unmount() - // on it; the view delegates the actual rendering to the legacy mixin - // methods (strangler fig). Adding a new instrument view is now a - // matter of dropping a XxxView.js + one registry rule — no change to - // this class or to setViewMode. - this._activeView = null; - this._activeViewKind = null; - // Options forwarded into the next view's ctx (e.g. { windPreset }). - this._pendingViewOptions = {}; - - // Setup event listeners - this.setupEventListeners(); - } - - // ======================================================================== - // I18N SUPPORT - // ======================================================================== - - /** - * Helper to translate a key - * @param {string} key - Translation key - * @param {Object} params - Interpolation parameters - * @returns {string} - Texte traduit - */ - t(key, params = {}) { - return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; - } - - /** - * Updates the translated content of the modal - */ - updateTranslations() { - if (!this.container) return; - - // Velocity - const velocityLabel = this.container.querySelector('#velocity-control-panel .velocity-label-vertical'); - if (velocityLabel) velocityLabel.textContent = this.t('keyboard.velocity'); - - // Modulation - const modulationLabel = this.container.querySelector('#modulation-control-panel .velocity-label-vertical'); - if (modulationLabel) modulationLabel.textContent = this.t('keyboard.modulation'); - - // Header group labels (resolved by their wrapping group classes) - const setLabel = (selector, key) => { - const el = this.container.querySelector(selector); - if (el) el.textContent = this.t(key); - }; - setLabel('.keyboard-header-controls .latency-group label', 'keyboard.latency'); - setLabel('.keyboard-header-controls .view-mode-group label', 'keyboard.view'); - setLabel('.keyboard-header-controls .notation-group label', 'keyboard.notation'); - - // Note range display - this._updateOctaveDisplay(); - - // Refresh instrument trigger placeholder/name - if (typeof this._updateInstrumentTrigger === 'function') { - this._updateInstrumentTrigger(); - } + constructor(logger = null, eventBus = null) { + this.backend = window.api; + this.logger = logger || console; + this.eventBus = eventBus || window.eventBus || null; + this.isOpen = false; + + // i18n support + this.localeUnsubscribe = null; + + // State + this.devices = []; + this.selectedDevice = null; + this.selectedDeviceCapabilities = null; // Selected instrument capabilities + this.activeNotes = new Set(); + this.mouseActiveNotes = new Set(); // Notes triggered by the mouse (for cleanup on global mouseup) + // In fretboard mode, tracks which specific string:fret positions are pressed + // (same MIDI note can exist on several strings — only the pressed one should highlight). + this.activeFretPositions = new Set(); + this.velocity = 80; + this.modulation = 64; // CC#1 modulation wheel value (center) + this._modWheelDragging = false; + // List view controls + this.listViewYCC = null; // null = vélocité, number = CC# pour l'axe Y + this.listViewPitchBendEnabled = true; // pitch bend actif sur le drag horizontal (X) + this.keyboardLayout = 'azerty'; + this.isMouseDown = false; // For dragging on the keyboard + + // Piano config + this.octaves = 3; // 3 octaves by default (range: 1-8 octaves) + this.minOctaves = 1; + this.maxOctaves = 8; + // Internal: number of visible notes (not always a multiple of 12 once + // the user zooms by 4-note increments). + this.visibleNoteCount = this.octaves * 12; + this.minVisibleNotes = 12; + this.maxVisibleNotes = 96; + this.zoomStep = 4; // semitones added/removed per zoom click/wheel tick + this.startNote = 48; // First MIDI note displayed (C3 by default) + this.defaultStartNote = 48; // Default value for reset + // White notes: relative semitones within an octave + this.whiteNoteOffsets = [0, 2, 4, 5, 7, 9, 11]; // C D E F G A B + // Semitones that have a black key (sharp) + this.blackNoteSemitones = new Set([1, 3, 6, 8, 10]); // C# D# F# G# A# + // Mapping tables for PC keys (generated dynamically) + this.visibleWhiteNotes = []; + this.visibleBlackNotes = []; + + // Note label format: 'english' (C/D/E), 'solfege' (Do/Ré/Mi), 'midi' (60) + this.noteLabelFormat = 'english'; + // View mode: 'piano' (default), 'fretboard' (string instr.), 'drumpad' (drum), 'piano-slider' (wind) + this.viewMode = 'piano'; + // Wind instrument state (set when a wind GM instrument is selected) + this.windPreset = null; + this.currentArticulation = 'normal'; + // Fretboard: show chromatic note colors (12 colors, one per semitone) + this.showNoteColors = false; + // String instrument config (loaded when fretboard mode is enabled) + this.stringInstrumentConfig = null; + // Harp string config (loaded for the dedicated HarpView; kept + // separate from stringInstrumentConfig so the InstrumentDetector + // escape hatch — stringCfg forces fretboard — stays untouched). + this._harpStringConfig = null; + // Per-string pitch bend slide mode active + this._stringSlideActive = false; + // Minimap drag state + this._minimapDragging = false; + + // The PC keyboard mapping is dynamic (see _resolveKeyToNote) + + // Bind handlers + this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); + this.handleGlobalMouseUp = this.handleGlobalMouseUp.bind(this); + + this.container = null; + + // Panel mode (embedded in a host container) + this._panelMode = false; + this._panelCallbacks = null; + this._panelDialogEl = null; + + // InstrumentView migration (Phase C/D complete): the registry is the + // authoritative owner of the active view's lifecycle. setViewMode() + // resolves a registered InstrumentView and drives mount()/unmount() + // on it; the view delegates the actual rendering to the legacy mixin + // methods (strangler fig). Adding a new instrument view is now a + // matter of dropping a XxxView.js + one registry rule — no change to + // this class or to setViewMode. + this._activeView = null; + this._activeViewKind = null; + // Options forwarded into the next view's ctx (e.g. { windPreset }). + this._pendingViewOptions = {}; + + // Setup event listeners + this.setupEventListeners(); + } + + // ======================================================================== + // I18N SUPPORT + // ======================================================================== + + /** + * Helper to translate a key + * @param {string} key - Translation key + * @param {Object} params - Interpolation parameters + * @returns {string} - Texte traduit + */ + t(key, params = {}) { + return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + } + + /** + * Updates the translated content of the modal + */ + updateTranslations() { + if (!this.container) return; + + // Velocity + const velocityLabel = this.container.querySelector( + '#velocity-control-panel .velocity-label-vertical' + ); + if (velocityLabel) velocityLabel.textContent = this.t('keyboard.velocity'); + + // Modulation + const modulationLabel = this.container.querySelector( + '#modulation-control-panel .velocity-label-vertical' + ); + if (modulationLabel) modulationLabel.textContent = this.t('keyboard.modulation'); + + // Header group labels (resolved by their wrapping group classes) + const setLabel = (selector, key) => { + const el = this.container.querySelector(selector); + if (el) el.textContent = this.t(key); + }; + setLabel('.keyboard-header-controls .latency-group label', 'keyboard.latency'); + setLabel('.keyboard-header-controls .view-mode-group label', 'keyboard.view'); + setLabel('.keyboard-header-controls .notation-group label', 'keyboard.notation'); + + // Note range display + this._updateOctaveDisplay(); + + // Refresh instrument trigger placeholder/name + if (typeof this._updateInstrumentTrigger === 'function') { + this._updateInstrumentTrigger(); } + } - // ======================================================================== - // EVENTS - // ======================================================================== - - setupEventListeners() { - if (!this.eventBus) { - this.logger.warn('[KeyboardModal] No eventBus available - device list will not auto-refresh'); - return; - } - - const refresh = async () => { - if (this.isOpen) { - await this.loadDevices(); - this.populateDeviceSelect(); - } - }; - - this._eventUnsubs = [ - this.eventBus.on('bluetooth:connected', async (_data) => { this.logger.info('[KeyboardModal] Bluetooth device connected, refreshing device list...'); await refresh(); }), - this.eventBus.on('bluetooth:disconnected', async (_data) => { this.logger.info('[KeyboardModal] Bluetooth device disconnected, refreshing device list...'); await refresh(); }), - this.eventBus.on('bluetooth:unpaired', async (_data) => { this.logger.info('[KeyboardModal] Bluetooth device unpaired, refreshing device list...'); await refresh(); }), - ]; + // ======================================================================== + // EVENTS + // ======================================================================== - this.logger.debug('[KeyboardModal] Event listeners configured'); + setupEventListeners() { + if (!this.eventBus) { + this.logger.warn('[KeyboardModal] No eventBus available - device list will not auto-refresh'); + return; } - // ======================================================================== - // OPEN / CLOSE - // ======================================================================== - - async open() { - if (this.isOpen) return; - - // Load saved settings to apply the key count - this.loadSettings(); - - this.createModal(); - this.isOpen = true; - - // Load devices + const refresh = async () => { + if (this.isOpen) { await this.loadDevices(); this.populateDeviceSelect(); + } + }; + + this._eventUnsubs = [ + this.eventBus.on('bluetooth:connected', async (_data) => { + this.logger.info('[KeyboardModal] Bluetooth device connected, refreshing device list...'); + await refresh(); + }), + this.eventBus.on('bluetooth:disconnected', async (_data) => { + this.logger.info( + '[KeyboardModal] Bluetooth device disconnected, refreshing device list...' + ); + await refresh(); + }), + this.eventBus.on('bluetooth:unpaired', async (_data) => { + this.logger.info('[KeyboardModal] Bluetooth device unpaired, refreshing device list...'); + await refresh(); + }) + ]; - // Attach events - this.attachEvents(); - - // Initialize slider visibility (hide modulation by default) - this.updateSlidersVisibility(); - - // Subscribe to locale changes - this._subscribeLocale(); - - this.logger.info('[KeyboardModal] Opened'); - } - - close() { - if (!this.isOpen) return; - - this.detachEvents(); - - // Unsubscribe from locale changes - if (this.localeUnsubscribe) { - this.localeUnsubscribe(); - this.localeUnsubscribe = null; - } - - // Unsubscribe from EventBus events - if (this._eventUnsubs) { - this._eventUnsubs.forEach(unsub => { if (typeof unsub === 'function') unsub(); }); - this._eventUnsubs = []; - } - - // Phase E (KM-M3): remove any DOM listener registered through _on(). - // Existing call sites still rely on detachEvents() above; new code - // should prefer this._on() for automatic cleanup. - if (typeof this._offAll === 'function') this._offAll(); - - // Clean up string slide mode - if (typeof this.destroyStringSliders === 'function') this.destroyStringSliders(); - - // Release any held bow (bowed-string instruments) so document - // listeners and CC state don't leak across modal lifecycles. - if (typeof this._stopActiveBow === 'function') this._stopActiveBow(); - - // Clean up keyboard list view interaction - if (typeof this._destroyKeyboardListInteraction === 'function') this._destroyKeyboardListInteraction(); + this.logger.debug('[KeyboardModal] Event listeners configured'); + } - // Destroy the fingers overlay renderer - if (typeof this._cleanFingersCanvas === 'function') this._cleanFingersCanvas(); + // ======================================================================== + // OPEN / CLOSE + // ======================================================================== - // Clean up wind instrument controls and staccato timers - if (typeof this._hideWindControls === 'function') this._hideWindControls(); + async open() { + if (this.isOpen) return; - // Tear down the active InstrumentView. Without this its document-level - // listeners (pointerup/pointercancel registered in mount()) leak across - // every open/close cycle, and — because _activeView/_activeViewKind - // would survive — a reopen would hit the _activateView() same-kind - // fast-path and never re-mount, leaving the view bound to DOM removed - // below. - if (this._activeView && typeof this._activeView.unmount === 'function') { - try { this._activeView.unmount(); } - catch (e) { this.logger.warn('[KeyboardModal] view.unmount() in close() failed:', e); } - } - this._activeView = null; - this._activeViewKind = null; + // Load saved settings to apply the key count + this.loadSettings(); - // Stop all active notes - this.activeNotes.forEach(note => this.stopNote(note)); + this.createModal(); + this.isOpen = true; - // Reset state - this.isMouseDown = false; - this.mouseActiveNotes.clear(); - this.activeFretPositions.clear(); - this.selectedDevice = null; + // Load devices + await this.loadDevices(); + this.populateDeviceSelect(); - if (this.container) { - this.container.remove(); - this.container = null; - } + // Attach events + this.attachEvents(); - this.isOpen = false; - this.logger.info('[KeyboardModal] Closed'); - } - - updatePianoDisplay() { - if (this.viewMode === 'fretboard') { - // In fretboard mode, highlight only the specific string:fret that was pressed. - // The same MIDI note can appear on several strings — activeFretPositions - // tracks exactly which dot was touched, so other strings stay unlit. - document.querySelectorAll('.fretboard-container .fret-dot.piano-key').forEach(dot => { - const pos = (dot.dataset.string !== undefined && dot.dataset.fret !== undefined) - ? `${dot.dataset.string}:${dot.dataset.fret}` - : null; - dot.classList.toggle('active', pos !== null && this.activeFretPositions.has(pos)); - }); - } else { - document.querySelectorAll('.piano-key').forEach(key => { - key.classList.toggle('active', this.activeNotes.has(parseInt(key.dataset.note))); - }); - } + // Initialize slider visibility (hide modulation by default) + this.updateSlidersVisibility(); - // Color the string line to the right of the active fret. - if (this.viewMode === 'fretboard' && typeof this._updateFretboardStringColors === 'function') { - this._updateFretboardStringColors(); - } + // Subscribe to locale changes + this._subscribeLocale(); - // Move slide-system finger dots to the active fret position (≈8 mm before fret). - if (this.viewMode === 'fretboard' && typeof this._updateSlideFingerPositions === 'function') { - this._updateSlideFingerPositions(); - } + this.logger.info('[KeyboardModal] Opened'); + } - // Move fret_sliding_fingers overlay dots to match active string:fret presses. - if (this.viewMode === 'fretboard' && typeof this._updateFingerDotPositions === 'function') { - const activeFrets = {}; - for (const pos of this.activeFretPositions) { - const parts = pos.split(':'); - const str = parseInt(parts[0], 10); - const fret = parseInt(parts[1], 10); - if (!isNaN(str) && !isNaN(fret) && fret > 0) activeFrets[str] = fret; - } - this._updateFingerDotPositions(activeFrets); - } + close() { + if (!this.isOpen) return; - // Keep the fingers overlay in sync with the currently-sounding keys. - if (typeof this._updateFingersActiveNotes === 'function') { - this._updateFingersActiveNotes(); - } + this.detachEvents(); - // Self-owned views render non-`.piano-key` DOM the legacy query - // above never reaches; delegate the highlight. No-op for the - // strangler-fig views (base setActiveNotes() does nothing). - if (this._activeView && typeof this._activeView.setActiveNotes === 'function') { - this._activeView.setActiveNotes(this.activeNotes); - } + // Unsubscribe from locale changes + if (this.localeUnsubscribe) { + this.localeUnsubscribe(); + this.localeUnsubscribe = null; } - /** - * Load an instrument's capabilities - * @param {string} deviceId - Device ID - * @param {number} [channel] - MIDI channel (for multi-instrument devices) - */ - async loadDeviceCapabilities(deviceId, channel) { - if (!deviceId) { - this.selectedDeviceCapabilities = null; - return; - } + // Unsubscribe from EventBus events + if (this._eventUnsubs) { + this._eventUnsubs.forEach((unsub) => { + if (typeof unsub === 'function') unsub(); + }); + this._eventUnsubs = []; + } - try { - const params = { deviceId }; - if (channel !== undefined) { - params.channel = channel; - } - const response = await this.backend.sendCommand('instrument_get_capabilities', params); - this.selectedDeviceCapabilities = response.capabilities || null; - this.logger.info(`[KeyboardModal] Capacités chargées pour ${deviceId} ch${channel}:`, this.selectedDeviceCapabilities); - } catch (error) { - this.logger.warn(`[KeyboardModal] Impossible de charger les capacités pour ${deviceId}:`, error); - this.selectedDeviceCapabilities = null; - } + // Phase E (KM-M3): remove any DOM listener registered through _on(). + // Existing call sites still rely on detachEvents() above; new code + // should prefer this._on() for automatic cleanup. + if (typeof this._offAll === 'function') this._offAll(); + + // Clean up string slide mode + if (typeof this.destroyStringSliders === 'function') this.destroyStringSliders(); + + // Release any held bow (bowed-string instruments) so document + // listeners and CC state don't leak across modal lifecycles. + if (typeof this._stopActiveBow === 'function') this._stopActiveBow(); + + // Clean up keyboard list view interaction + if (typeof this._destroyKeyboardListInteraction === 'function') + this._destroyKeyboardListInteraction(); + + // Destroy the fingers overlay renderer + if (typeof this._cleanFingersCanvas === 'function') this._cleanFingersCanvas(); + + // Clean up wind instrument controls and staccato timers + if (typeof this._hideWindControls === 'function') this._hideWindControls(); + + // Tear down the active InstrumentView. Without this its document-level + // listeners (pointerup/pointercancel registered in mount()) leak across + // every open/close cycle, and — because _activeView/_activeViewKind + // would survive — a reopen would hit the _activateView() same-kind + // fast-path and never re-mount, leaving the view bound to DOM removed + // below. + if (this._activeView && typeof this._activeView.unmount === 'function') { + try { + this._activeView.unmount(); + } catch (e) { + this.logger.warn('[KeyboardModal] view.unmount() in close() failed:', e); + } } + this._activeView = null; + this._activeViewKind = null; - regeneratePianoKeys() { - if (this.viewMode === 'piano-slider') { - // In slider mode, regenerate the equal-width strip instead - if (typeof this.generatePianoSlider === 'function') this.generatePianoSlider(); - if (typeof this.renderMinimap === 'function') this.renderMinimap(); - if (typeof this.renderOctaveBar === 'function') this.renderOctaveBar(); - return; - } + // Stop all active notes + this.activeNotes.forEach((note) => this.stopNote(note)); - if (this.viewMode === 'keyboard-list') { - if (typeof this.renderKeyboardList === 'function') this.renderKeyboardList(); - if (typeof this.renderMinimap === 'function') this.renderMinimap(); - return; - } + // Reset state + this.isMouseDown = false; + this.mouseActiveNotes.clear(); + this.activeFretPositions.clear(); + this.selectedDevice = null; - this.generatePianoKeys(); + if (this.container) { + this.container.remove(); + this.container = null; + } - // Event delegation: a single listener on the container instead of 6 per key - this._setupPianoDelegation(); + this.isOpen = false; + this.logger.info('[KeyboardModal] Closed'); + } + + updatePianoDisplay() { + if (this.viewMode === 'fretboard') { + // In fretboard mode, highlight only the specific string:fret that was pressed. + // The same MIDI note can appear on several strings — activeFretPositions + // tracks exactly which dot was touched, so other strings stay unlit. + document.querySelectorAll('.fretboard-container .fret-dot.piano-key').forEach((dot) => { + const pos = + dot.dataset.string !== undefined && dot.dataset.fret !== undefined + ? `${dot.dataset.string}:${dot.dataset.fret}` + : null; + dot.classList.toggle('active', pos !== null && this.activeFretPositions.has(pos)); + }); + } else { + document.querySelectorAll('.piano-key').forEach((key) => { + key.classList.toggle('active', this.activeNotes.has(parseInt(key.dataset.note))); + }); + } - // Refresh the navigation aids that depend on visible notes. - if (typeof this.renderMinimap === 'function') this.renderMinimap(); - if (typeof this.renderOctaveBar === 'function') this.renderOctaveBar(); + // Color the string line to the right of the active fret. + if (this.viewMode === 'fretboard' && typeof this._updateFretboardStringColors === 'function') { + this._updateFretboardStringColors(); + } - this.updatePianoDisplay(); + // Move slide-system finger dots to the active fret position (≈8 mm before fret). + if (this.viewMode === 'fretboard' && typeof this._updateSlideFingerPositions === 'function') { + this._updateSlideFingerPositions(); } - /** - * Remove delegated piano container listeners. - * - * Pairs with KeyboardPiano._setupPianoDelegation. The new - * implementation (Phase F swipe fix) tracks listeners in a single - * `_pianoListeners` array and routes drag events through a - * SwipeTracker — so cleanup is a uniform iteration. - */ - _removePianoDelegation() { - if (this._swipeTracker) { - // End any in-flight pointer so we don't leak a note-on without - // its matching note-off. - this._swipeTracker.endAll(); - this._swipeTracker = null; - } - if (Array.isArray(this._pianoListeners)) { - for (const [el, evt, h, opts] of this._pianoListeners) { - try { el.removeEventListener(evt, h, opts); } catch (_) { /* ignore */ } - } - this._pianoListeners = []; - } + // Move fret_sliding_fingers overlay dots to match active string:fret presses. + if (this.viewMode === 'fretboard' && typeof this._updateFingerDotPositions === 'function') { + const activeFrets = {}; + for (const pos of this.activeFretPositions) { + const parts = pos.split(':'); + const str = parseInt(parts[0], 10); + const fret = parseInt(parts[1], 10); + if (!isNaN(str) && !isNaN(fret) && fret > 0) activeFrets[str] = fret; + } + this._updateFingerDotPositions(activeFrets); } - // ======================================================================== - // INSTRUMENT-VIEW LIFECYCLE (registry-driven — KM-C1) - // ======================================================================== - - /** - * Build the ViewContext handed to every InstrumentView.mount(). - * The view delegates rendering back through `modal.*` (Phase D), and - * reads shared state from the same object. - * @param {Object} options - Per-view options (e.g. { windPreset }). - * @returns {Object} - */ - _buildViewContext(options = {}) { - return { - modal: this, - state: this, - backend: this.backend, - eventBus: this.eventBus, - i18n: { t: (k, p) => this.t(k, p) }, - capabilities: this.selectedDeviceCapabilities, - options: options || {} - }; - } - - /** - * Resolve the registered InstrumentView for `viewKind` and drive its - * lifecycle. This is the single place that owns "which view is active": - * - same kind already mounted → refresh via setCapabilities() - * - different kind → unmount the previous view, mount the new one - * - registry/class unavailable → fall back to the legacy render switch - * - * The resolved view's mount() performs the actual DOM render by - * delegating to the legacy mixin methods (renderFretboard, etc.). - * - * @param {string} viewKind - 'piano' | 'fretboard' | 'drumpad' | - * 'piano-slider' | 'keyboard-list' | … - * @param {Object} [options] - Forwarded into the view ctx. - */ - _activateView(viewKind, options = {}) { - const registry = (typeof window !== 'undefined' && window.instrumentViews) || null; - const ViewClass = registry && typeof registry.get === 'function' - ? registry.get(viewKind) - : null; + // Keep the fingers overlay in sync with the currently-sounding keys. + if (typeof this._updateFingersActiveNotes === 'function') { + this._updateFingersActiveNotes(); + } - // No registered view (older host, test without registry, or a brand - // new kind not yet implemented) → keep the modal working via the - // legacy render switch. This guarantees zero regression. - if (typeof ViewClass !== 'function') { - this._activeView = null; - this._activeViewKind = viewKind; - this._legacyRenderForMode(viewKind); - return; - } + // Self-owned views render non-`.piano-key` DOM the legacy query + // above never reaches; delegate the highlight. No-op for the + // strangler-fig views (base setActiveNotes() does nothing). + if (this._activeView && typeof this._activeView.setActiveNotes === 'function') { + this._activeView.setActiveNotes(this.activeNotes); + } + } + + /** + * Load an instrument's capabilities + * @param {string} deviceId - Device ID + * @param {number} [channel] - MIDI channel (for multi-instrument devices) + */ + async loadDeviceCapabilities(deviceId, channel) { + if (!deviceId) { + this.selectedDeviceCapabilities = null; + return; + } - // Same kind still active → just refresh capabilities, no teardown. - if (this._activeView && this._activeViewKind === viewKind && this._activeView.mounted) { - if (typeof this._activeView.setCapabilities === 'function') { - this._activeView.setCapabilities(this.selectedDeviceCapabilities); - } - return; - } + try { + const params = { deviceId }; + if (channel !== undefined) { + params.channel = channel; + } + const response = await this.backend.sendCommand('instrument_get_capabilities', params); + this.selectedDeviceCapabilities = response.capabilities || null; + this.logger.info( + `[KeyboardModal] Capacités chargées pour ${deviceId} ch${channel}:`, + this.selectedDeviceCapabilities + ); + } catch (error) { + this.logger.warn( + `[KeyboardModal] Impossible de charger les capacités pour ${deviceId}:`, + error + ); + this.selectedDeviceCapabilities = null; + } + } + + regeneratePianoKeys() { + if (this.viewMode === 'piano-slider') { + // In slider mode, regenerate the equal-width strip instead + if (typeof this.generatePianoSlider === 'function') this.generatePianoSlider(); + if (typeof this.renderMinimap === 'function') this.renderMinimap(); + if (typeof this.renderOctaveBar === 'function') this.renderOctaveBar(); + return; + } - // Tear down the previous view (its unmount() releases view-specific - // interaction state: string sliders, bow, list interaction, …). - if (this._activeView && typeof this._activeView.unmount === 'function') { - try { this._activeView.unmount(); } - catch (e) { this.logger.warn('[KeyboardModal] view.unmount() failed:', e); } - } + if (this.viewMode === 'keyboard-list') { + if (typeof this.renderKeyboardList === 'function') this.renderKeyboardList(); + if (typeof this.renderMinimap === 'function') this.renderMinimap(); + return; + } - let view = null; + this.generatePianoKeys(); + + // Event delegation: a single listener on the container instead of 6 per key + this._setupPianoDelegation(); + + // Refresh the navigation aids that depend on visible notes. + if (typeof this.renderMinimap === 'function') this.renderMinimap(); + if (typeof this.renderOctaveBar === 'function') this.renderOctaveBar(); + + this.updatePianoDisplay(); + } + + /** + * Remove delegated piano container listeners. + * + * Pairs with KeyboardPiano._setupPianoDelegation. The new + * implementation (Phase F swipe fix) tracks listeners in a single + * `_pianoListeners` array and routes drag events through a + * SwipeTracker — so cleanup is a uniform iteration. + */ + _removePianoDelegation() { + if (this._swipeTracker) { + // End any in-flight pointer so we don't leak a note-on without + // its matching note-off. + this._swipeTracker.endAll(); + this._swipeTracker = null; + } + if (Array.isArray(this._pianoListeners)) { + for (const [el, evt, h, opts] of this._pianoListeners) { try { - view = new ViewClass(); - view.mount(this._buildViewContext(options)); - } catch (e) { - this.logger.error(`[KeyboardModal] view "${viewKind}" mount failed, using legacy render:`, e); - this._activeView = null; - this._activeViewKind = viewKind; - this._legacyRenderForMode(viewKind); - return; - } - - this._activeView = view; - this._activeViewKind = viewKind; - } - - /** - * Legacy render fallback — mirrors the historical tail of setViewMode(). - * Only used when no InstrumentView is registered for the kind (defensive; - * the built-in 5 kinds are always registered via registerBuiltins.js). - * @param {string} mode - */ - _legacyRenderForMode(mode) { - if (mode === 'fretboard' && typeof this.renderFretboard === 'function') this.renderFretboard(); - else if (mode === 'drumpad' && typeof this.renderDrumPad === 'function') this.renderDrumPad(); - else if (mode === 'piano-slider' && typeof this.generatePianoSlider === 'function') this.generatePianoSlider(); - else if (mode === 'keyboard-list' && typeof this.renderKeyboardList === 'function') this.renderKeyboardList(); - else if (typeof this.regeneratePianoKeys === 'function') this.regeneratePianoKeys(); - } - - /** - * PE-2: single owner of the *mode-only* toolbar-group visibility - * (octave bar, minimap, note-color group, list-view group). Pure - * extraction of the formulas previously inlined in setViewMode — - * behaviour is intentionally IDENTICAL (zero-regression). - * - * Mode-only group visibility is driven solely by `this.viewMode`. - * Caps-aware groups (velocity/mod/pitch/slide/piano-slider/list-cc/ - * list-pb/wind) stay owned by updateSlidersVisibility / - * _updateListViewControls / _updateSlideModeGroupVisibility / - * _updatePianoSliderGroupVisibility. The view-mode group is owned by - * _selectInstrumentOption (F2). - */ - _applyToolbarGroups() { - const vm = this.viewMode; - const set = (id, hidden) => { - const el = document.getElementById(id); - if (el) el.classList.toggle('hidden', hidden); - }; - // Octave bar: standard piano + piano-slider only. - set('keyboard-octave-bar', vm !== 'piano' && vm !== 'piano-slider'); - // Minimap: piano-family (piano / piano-slider / keyboard-list). - const isPianoFamily = vm === 'piano' || vm === 'piano-slider' || vm === 'keyboard-list'; - set('keyboard-minimap-row', !isPianoFamily); - // Note-color (🎨) toggle: QA #3 — must stay VISIBLE on piano-slider - // (and everywhere else); only a drum kit has no pitch colours, so - // hide it solely in drumpad. - set('keyboard-note-color-group', vm === 'drumpad'); - // List-view toggle: hidden in fretboard / drumpad context. - set('keyboard-list-view-group', vm === 'fretboard' || vm === 'drumpad'); - } - - /** - * Set the number of keyboard octaves - * @param {number} octaves - Number of octaves (1-4) - */ - setOctaves(octaves) { - // Clamp between min and max octaves - this.octaves = Math.max(this.minOctaves, Math.min(this.maxOctaves, octaves)); - this.visibleNoteCount = this.octaves * 12; - - this.logger.info(`[KeyboardModal] Nombre d'octaves changé: ${this.octaves} (${this.visibleNoteCount} touches)`); - - // Keep header select in sync - const select = document.getElementById('keyboard-octaves-count-select'); - if (select && parseInt(select.value) !== this.octaves) { - select.value = String(this.octaves); - } - - // Regenerate the keyboard if the modal is open - if (this.isOpen) { - this.regeneratePianoKeys(); + el.removeEventListener(evt, h, opts); + } catch (_) { + /* ignore */ } + } + this._pianoListeners = []; + } + } + + // ======================================================================== + // INSTRUMENT-VIEW LIFECYCLE (registry-driven — KM-C1) + // ======================================================================== + + /** + * Build the ViewContext handed to every InstrumentView.mount(). + * The view delegates rendering back through `modal.*` (Phase D), and + * reads shared state from the same object. + * @param {Object} options - Per-view options (e.g. { windPreset }). + * @returns {Object} + */ + _buildViewContext(options = {}) { + return { + modal: this, + state: this, + backend: this.backend, + eventBus: this.eventBus, + i18n: { t: (k, p) => this.t(k, p) }, + capabilities: this.selectedDeviceCapabilities, + options: options || {} + }; + } + + /** + * Resolve the registered InstrumentView for `viewKind` and drive its + * lifecycle. This is the single place that owns "which view is active": + * - same kind already mounted → refresh via setCapabilities() + * - different kind → unmount the previous view, mount the new one + * - registry/class unavailable → fall back to the legacy render switch + * + * The resolved view's mount() performs the actual DOM render by + * delegating to the legacy mixin methods (renderFretboard, etc.). + * + * @param {string} viewKind - 'piano' | 'fretboard' | 'drumpad' | + * 'piano-slider' | 'keyboard-list' | … + * @param {Object} [options] - Forwarded into the view ctx. + */ + _activateView(viewKind, options = {}) { + const registry = (typeof window !== 'undefined' && window.instrumentViews) || null; + const ViewClass = + registry && typeof registry.get === 'function' ? registry.get(viewKind) : null; + + // No registered view (older host, test without registry, or a brand + // new kind not yet implemented) → keep the modal working via the + // legacy render switch. This guarantees zero regression. + if (typeof ViewClass !== 'function') { + this._activeView = null; + this._activeViewKind = viewKind; + this._legacyRenderForMode(viewKind); + return; } - /** - * Set the raw number of visible notes (not necessarily a multiple of 12). - * Used by the zoom buttons / wheel which step by `this.zoomStep` semitones. - */ - setVisibleNotes(count) { - const clamped = Math.max(this.minVisibleNotes, Math.min(this.maxVisibleNotes, count)); - this.visibleNoteCount = clamped; - // Keep `this.octaves` loosely in sync for downstream code that still - // reads it (header dropdown, persisted settings). - this.octaves = Math.max(this.minOctaves, Math.min(this.maxOctaves, Math.round(clamped / 12))); - // Keep startNote within bounds for the new visible count. - this.startNote = Math.max(0, Math.min(127 - this.visibleNoteCount, this.startNote)); - - const select = document.getElementById('keyboard-octaves-count-select'); - if (select) { - // Only reflect on the dropdown when the count matches a clean octave. - if (clamped % 12 === 0) { - select.value = String(this.octaves); - } else { - select.value = ''; - } - } + // Same kind still active → just refresh capabilities, no teardown. + if (this._activeView && this._activeViewKind === viewKind && this._activeView.mounted) { + if (typeof this._activeView.setCapabilities === 'function') { + this._activeView.setCapabilities(this.selectedDeviceCapabilities); + } + return; } - /** - * Persist the current octave count to localStorage - */ - saveOctavesToSettings() { - try { - const saved = localStorage.getItem('gmboop_settings'); - const settings = saved ? JSON.parse(saved) : {}; - settings.keyboardOctaves = this.octaves; - localStorage.setItem('gmboop_settings', JSON.stringify(settings)); - } catch (error) { - this.logger.error('[KeyboardModal] Failed to save octaves:', error); - } + // Tear down the previous view (its unmount() releases view-specific + // interaction state: string sliders, bow, list interaction, …). + if (this._activeView && typeof this._activeView.unmount === 'function') { + try { + this._activeView.unmount(); + } catch (e) { + this.logger.warn('[KeyboardModal] view.unmount() failed:', e); + } } - /** - * Set the number of keyboard keys (DEPRECATED - use setOctaves) - * @param {number} numberOfKeys - Number of keys (12-48 keys) - * @deprecated Use setOctaves() instead - */ - setNumberOfKeys(numberOfKeys) { - // Compute the number of octaves to display - const octaves = Math.ceil(numberOfKeys / 12); - this.setOctaves(octaves); + let view = null; + try { + view = new ViewClass(); + view.mount(this._buildViewContext(options)); + } catch (e) { + this.logger.error(`[KeyboardModal] view "${viewKind}" mount failed, using legacy render:`, e); + this._activeView = null; + this._activeViewKind = viewKind; + this._legacyRenderForMode(viewKind); + return; } - handleGlobalMouseUp() { - this.isMouseDown = false; + this._activeView = view; + this._activeViewKind = viewKind; + } + + /** + * Legacy render fallback — mirrors the historical tail of setViewMode(). + * Only used when no InstrumentView is registered for the kind (defensive; + * the built-in 5 kinds are always registered via registerBuiltins.js). + * @param {string} mode + */ + _legacyRenderForMode(mode) { + if (mode === 'fretboard' && typeof this.renderFretboard === 'function') this.renderFretboard(); + else if (mode === 'drumpad' && typeof this.renderDrumPad === 'function') this.renderDrumPad(); + else if (mode === 'piano-slider' && typeof this.generatePianoSlider === 'function') + this.generatePianoSlider(); + else if (mode === 'keyboard-list' && typeof this.renderKeyboardList === 'function') + this.renderKeyboardList(); + else if (typeof this.regeneratePianoKeys === 'function') this.regeneratePianoKeys(); + } + + /** + * PE-2: single owner of the *mode-only* toolbar-group visibility + * (octave bar, minimap, note-color group, list-view group). Pure + * extraction of the formulas previously inlined in setViewMode — + * behaviour is intentionally IDENTICAL (zero-regression). + * + * Mode-only group visibility is driven solely by `this.viewMode`. + * Caps-aware groups (velocity/mod/pitch/slide/piano-slider/list-cc/ + * list-pb/wind) stay owned by updateSlidersVisibility / + * _updateListViewControls / _updateSlideModeGroupVisibility / + * _updatePianoSliderGroupVisibility. The view-mode group is owned by + * _selectInstrumentOption (F2). + */ + _applyToolbarGroups() { + const vm = this.viewMode; + const set = (id, hidden) => { + const el = document.getElementById(id); + if (el) el.classList.toggle('hidden', hidden); + }; + // Octave bar: standard piano + piano-slider only. + set('keyboard-octave-bar', vm !== 'piano' && vm !== 'piano-slider'); + // Minimap: piano-family (piano / piano-slider / keyboard-list). + const isPianoFamily = vm === 'piano' || vm === 'piano-slider' || vm === 'keyboard-list'; + set('keyboard-minimap-row', !isPianoFamily); + // Note-color (🎨) toggle: QA #3 — must stay VISIBLE on piano-slider + // (and everywhere else); only a drum kit has no pitch colours, so + // hide it solely in drumpad. + set('keyboard-note-color-group', vm === 'drumpad'); + // List-view toggle: hidden in fretboard / drumpad context. + set('keyboard-list-view-group', vm === 'fretboard' || vm === 'drumpad'); + } + + /** + * Set the number of keyboard octaves + * @param {number} octaves - Number of octaves (1-4) + */ + setOctaves(octaves) { + // Clamp between min and max octaves + this.octaves = Math.max(this.minOctaves, Math.min(this.maxOctaves, octaves)); + this.visibleNoteCount = this.octaves * 12; + + this.logger.info( + `[KeyboardModal] Nombre d'octaves changé: ${this.octaves} (${this.visibleNoteCount} touches)` + ); + + // Keep header select in sync + const select = document.getElementById('keyboard-octaves-count-select'); + if (select && parseInt(select.value) !== this.octaves) { + select.value = String(this.octaves); + } - // Stop all notes triggered by the mouse - // (avoids "stuck" notes if the mouseup happens outside a key) - if (this.mouseActiveNotes.size > 0) { - for (const note of this.mouseActiveNotes) { - this.stopNote(note); - } - this.mouseActiveNotes.clear(); - } - this.activeFretPositions.clear(); + // Regenerate the keyboard if the modal is open + if (this.isOpen) { + this.regeneratePianoKeys(); + } + } + + /** + * Set the raw number of visible notes (not necessarily a multiple of 12). + * Used by the zoom buttons / wheel which step by `this.zoomStep` semitones. + */ + setVisibleNotes(count) { + const clamped = Math.max(this.minVisibleNotes, Math.min(this.maxVisibleNotes, count)); + this.visibleNoteCount = clamped; + // Keep `this.octaves` loosely in sync for downstream code that still + // reads it (header dropdown, persisted settings). + this.octaves = Math.max(this.minOctaves, Math.min(this.maxOctaves, Math.round(clamped / 12))); + // Keep startNote within bounds for the new visible count. + this.startNote = Math.max(0, Math.min(127 - this.visibleNoteCount, this.startNote)); + + const select = document.getElementById('keyboard-octaves-count-select'); + if (select) { + // Only reflect on the dropdown when the count matches a clean octave. + if (clamped % 12 === 0) { + select.value = String(this.octaves); + } else { + select.value = ''; + } + } + } + + /** + * Persist the current octave count to localStorage + */ + saveOctavesToSettings() { + try { + const saved = localStorage.getItem('gmboop_settings'); + const settings = saved ? JSON.parse(saved) : {}; + settings.keyboardOctaves = this.octaves; + localStorage.setItem('gmboop_settings', JSON.stringify(settings)); + } catch (error) { + this.logger.error('[KeyboardModal] Failed to save octaves:', error); + } + } + + /** + * Set the number of keyboard keys (DEPRECATED - use setOctaves) + * @param {number} numberOfKeys - Number of keys (12-48 keys) + * @deprecated Use setOctaves() instead + */ + setNumberOfKeys(numberOfKeys) { + // Compute the number of octaves to display + const octaves = Math.ceil(numberOfKeys / 12); + this.setOctaves(octaves); + } + + handleGlobalMouseUp() { + this.isMouseDown = false; + + // Stop all notes triggered by the mouse + // (avoids "stuck" notes if the mouseup happens outside a key) + if (this.mouseActiveNotes.size > 0) { + for (const note of this.mouseActiveNotes) { + this.stopNote(note); + } + this.mouseActiveNotes.clear(); } + this.activeFretPositions.clear(); + } - handlePianoKeyDown(e) { - this.isMouseDown = true; - const key = e.currentTarget; - const note = parseInt(key.dataset.note); + handlePianoKeyDown(e) { + this.isMouseDown = true; + const key = e.currentTarget; + const note = parseInt(key.dataset.note); - // Don't play if the key is disabled - if (key.classList.contains('disabled')) { - return; - } + // Don't play if the key is disabled + if (key.classList.contains('disabled')) { + return; + } - // Track specific fretboard position BEFORE playNote triggers updatePianoDisplay. - if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { - this.activeFretPositions.add(`${key.dataset.string}:${key.dataset.fret}`); - } + // Track specific fretboard position BEFORE playNote triggers updatePianoDisplay. + if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { + this.activeFretPositions.add(`${key.dataset.string}:${key.dataset.fret}`); + } - // Auto-move hand if the clicked fret is outside the current hand window. - if (key.dataset.fret !== undefined && typeof this._maybeAutoMoveHand === 'function') { - this._maybeAutoMoveHand(parseInt(key.dataset.fret, 10)); - } + // Auto-move hand if the clicked fret is outside the current hand window. + if (key.dataset.fret !== undefined && typeof this._maybeAutoMoveHand === 'function') { + this._maybeAutoMoveHand(parseInt(key.dataset.fret, 10)); + } - if (!this.activeNotes.has(note)) { - this.mouseActiveNotes.add(note); - // Fretboard cells carry data-string + data-fret so the receiving - // instrument can pre-position its mechanical fingers before the - // note-on. Send them right before playNote so the order on the - // wire matches the playback path used elsewhere in the app. - this._maybeSendStringFretCC(key); - this.playNote(note); - } + if (!this.activeNotes.has(note)) { + this.mouseActiveNotes.add(note); + // Fretboard cells carry data-string + data-fret so the receiving + // instrument can pre-position its mechanical fingers before the + // note-on. Send them right before playNote so the order on the + // wire matches the playback path used elsewhere in the app. + this._maybeSendStringFretCC(key); + this.playNote(note); } + } - handlePianoKeyUp(e) { - const key = e.currentTarget; - const note = parseInt(key.dataset.note); + handlePianoKeyUp(e) { + const key = e.currentTarget; + const note = parseInt(key.dataset.note); - // Release specific fretboard position. - if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { - this.activeFretPositions.delete(`${key.dataset.string}:${key.dataset.fret}`); - } + // Release specific fretboard position. + if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { + this.activeFretPositions.delete(`${key.dataset.string}:${key.dataset.fret}`); + } - this.mouseActiveNotes.delete(note); + this.mouseActiveNotes.delete(note); - // Stop the note only if it is active - if (this.activeNotes.has(note)) { - this.stopNote(note); - } + // Stop the note only if it is active + if (this.activeNotes.has(note)) { + this.stopNote(note); } + } - handlePianoKeyEnter(e) { - // Play the note only if the mouse is pressed (drag) - if (!this.isMouseDown) return; - - const key = e.currentTarget; - const note = parseInt(key.dataset.note); + handlePianoKeyEnter(e) { + // Play the note only if the mouse is pressed (drag) + if (!this.isMouseDown) return; - // Don't play if the key is disabled - if (key.classList.contains('disabled')) { - return; - } + const key = e.currentTarget; + const note = parseInt(key.dataset.note); - // Track specific fretboard position BEFORE playNote triggers updatePianoDisplay. - if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { - this.activeFretPositions.add(`${key.dataset.string}:${key.dataset.fret}`); - } + // Don't play if the key is disabled + if (key.classList.contains('disabled')) { + return; + } - if (!this.activeNotes.has(note)) { - this.mouseActiveNotes.add(note); - this._maybeSendStringFretCC(key); - this.playNote(note); - } + // Track specific fretboard position BEFORE playNote triggers updatePianoDisplay. + if (key.dataset.string !== undefined && key.dataset.fret !== undefined) { + this.activeFretPositions.add(`${key.dataset.string}:${key.dataset.fret}`); } - /** - * If the clicked element belongs to the fretboard view, emit the - * configured "select string" + "select fret" CC messages so the - * instrument can pre-position its mechanical fingers before the note-on. - * Reads CC numbers / ranges / offsets from the active string-instrument - * config (or sensible defaults: CC20=string [1..12], CC21=fret [0..36]). - * @param {HTMLElement} keyEl - The key DOM node (.fret-dot) - */ - _maybeSendStringFretCC(keyEl) { - if (!keyEl || !keyEl.dataset || keyEl.dataset.string === undefined || keyEl.dataset.fret === undefined) { - return; - } - if (!this.selectedDevice || !this.backend) return; + if (!this.activeNotes.has(note)) { + this.mouseActiveNotes.add(note); + this._maybeSendStringFretCC(key); + this.playNote(note); + } + } + + /** + * If the clicked element belongs to the fretboard view, emit the + * configured "select string" + "select fret" CC messages so the + * instrument can pre-position its mechanical fingers before the note-on. + * Reads CC numbers / ranges / offsets from the active string-instrument + * config (or sensible defaults: CC20=string [1..12], CC21=fret [0..36]). + * @param {HTMLElement} keyEl - The key DOM node (.fret-dot) + */ + _maybeSendStringFretCC(keyEl) { + if ( + !keyEl || + !keyEl.dataset || + keyEl.dataset.string === undefined || + keyEl.dataset.fret === undefined + ) { + return; + } + if (!this.selectedDevice || !this.backend) return; + + const cfg = this.stringInstrumentConfig || {}; + if (cfg.cc_enabled === false) return; // explicitly disabled on this instrument + + const stringIdx = parseInt(keyEl.dataset.string, 10); + const fret = parseInt(keyEl.dataset.fret, 10); + if (!Number.isFinite(stringIdx) || !Number.isFinite(fret)) return; + + const ccStringNumber = cfg.cc_string_number !== undefined ? cfg.cc_string_number : 20; + const ccStringMin = cfg.cc_string_min !== undefined ? cfg.cc_string_min : 1; + const ccStringMax = cfg.cc_string_max !== undefined ? cfg.cc_string_max : 12; + const ccStringOffset = cfg.cc_string_offset || 0; + const ccFretNumber = cfg.cc_fret_number !== undefined ? cfg.cc_fret_number : 21; + const ccFretMin = cfg.cc_fret_min !== undefined ? cfg.cc_fret_min : 0; + const ccFretMax = cfg.cc_fret_max !== undefined ? cfg.cc_fret_max : 36; + const ccFretOffset = cfg.cc_fret_offset || 0; + + const clamp127 = (v, lo, hi) => Math.max(0, Math.min(127, Math.max(lo, Math.min(hi, v)))); + const stringVal = clamp127(stringIdx + ccStringOffset, ccStringMin, ccStringMax); + const fretVal = clamp127(fret + ccFretOffset, ccFretMin, ccFretMax); + + const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; + + if (this.selectedDevice.isVirtual) { + this.logger?.info?.( + `🎸 [Virtual] CC${ccStringNumber}=${stringVal} (string ${stringIdx}) CC${ccFretNumber}=${fretVal} (fret ${fret})` + ); + return; + } - const cfg = this.stringInstrumentConfig || {}; - if (cfg.cc_enabled === false) return; // explicitly disabled on this instrument + const channel = this.getSelectedChannel(); + this.backend + .sendCommand('midi_send_cc', { + deviceId, + channel, + controller: ccStringNumber, + value: stringVal + }) + .catch((err) => this.logger.error('[KeyboardModal] String CC send failed:', err)); + this.backend + .sendCommand('midi_send_cc', { + deviceId, + channel, + controller: ccFretNumber, + value: fretVal + }) + .catch((err) => this.logger.error('[KeyboardModal] Fret CC send failed:', err)); + } + + handleKeyDown(e) { + if (!this.isOpen) return; + if (document.activeElement?.matches('input, textarea, [contenteditable]')) return; + + const note = this._resolveKeyToNote(e.code); + if (note === null) return; + + e.preventDefault(); + + if (!this.activeNotes.has(note)) { + this.playNote(note); + } + } - const stringIdx = parseInt(keyEl.dataset.string, 10); - const fret = parseInt(keyEl.dataset.fret, 10); - if (!Number.isFinite(stringIdx) || !Number.isFinite(fret)) return; + handleKeyUp(e) { + if (!this.isOpen) return; + if (document.activeElement?.matches('input, textarea, [contenteditable]')) return; - const ccStringNumber = cfg.cc_string_number !== undefined ? cfg.cc_string_number : 20; - const ccStringMin = cfg.cc_string_min !== undefined ? cfg.cc_string_min : 1; - const ccStringMax = cfg.cc_string_max !== undefined ? cfg.cc_string_max : 12; - const ccStringOffset = cfg.cc_string_offset || 0; - const ccFretNumber = cfg.cc_fret_number !== undefined ? cfg.cc_fret_number : 21; - const ccFretMin = cfg.cc_fret_min !== undefined ? cfg.cc_fret_min : 0; - const ccFretMax = cfg.cc_fret_max !== undefined ? cfg.cc_fret_max : 36; - const ccFretOffset = cfg.cc_fret_offset || 0; + const note = this._resolveKeyToNote(e.code); + if (note === null) return; - const clamp127 = (v, lo, hi) => Math.max(0, Math.min(127, Math.max(lo, Math.min(hi, v)))); - const stringVal = clamp127(stringIdx + ccStringOffset, ccStringMin, ccStringMax); - const fretVal = clamp127(fret + ccFretOffset, ccFretMin, ccFretMax); + e.preventDefault(); - const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; + this.stopNote(note); + } - if (this.selectedDevice.isVirtual) { - this.logger?.info?.(`🎸 [Virtual] CC${ccStringNumber}=${stringVal} (string ${stringIdx}) CC${ccFretNumber}=${fretVal} (fret ${fret})`); - return; - } + // ======================================================================== + // MIDI + // ======================================================================== - const channel = this.getSelectedChannel(); - this.backend.sendCommand('midi_send_cc', { - deviceId, channel, controller: ccStringNumber, value: stringVal - }).catch(err => this.logger.error('[KeyboardModal] String CC send failed:', err)); - this.backend.sendCommand('midi_send_cc', { - deviceId, channel, controller: ccFretNumber, value: fretVal - }).catch(err => this.logger.error('[KeyboardModal] Fret CC send failed:', err)); + /** + * Return the MIDI channel of the selected instrument (from capabilities or the device) + * @returns {number} MIDI channel (0-15) + */ + getSelectedChannel() { + if (this.selectedDeviceCapabilities && this.selectedDeviceCapabilities.channel !== undefined) { + return this.selectedDeviceCapabilities.channel; } - - handleKeyDown(e) { - if (!this.isOpen) return; - if (document.activeElement?.matches('input, textarea, [contenteditable]')) return; - - const note = this._resolveKeyToNote(e.code); - if (note === null) return; - - e.preventDefault(); - - if (!this.activeNotes.has(note)) { - this.playNote(note); - } + if (this.selectedDevice && this.selectedDevice.channel !== undefined) { + return this.selectedDevice.channel; } + return 0; + } - handleKeyUp(e) { - if (!this.isOpen) return; - if (document.activeElement?.matches('input, textarea, [contenteditable]')) return; + sendModulation(value) { + if (!this.selectedDevice || !this.backend) return; - const note = this._resolveKeyToNote(e.code); - if (note === null) return; + const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; - e.preventDefault(); - - this.stopNote(note); + if (this.selectedDevice.isVirtual) { + this.logger.info(`🎹 [Virtual] Modulation CC#1 = ${value}`); + return; } - // ======================================================================== - // MIDI - // ======================================================================== - - /** - * Return the MIDI channel of the selected instrument (from capabilities or the device) - * @returns {number} MIDI channel (0-15) - */ - getSelectedChannel() { - if (this.selectedDeviceCapabilities && this.selectedDeviceCapabilities.channel !== undefined) { - return this.selectedDeviceCapabilities.channel; - } - if (this.selectedDevice && this.selectedDevice.channel !== undefined) { - return this.selectedDevice.channel; - } - return 0; + const channel = this.getSelectedChannel(); + this.backend + .sendCommand('midi_send_cc', { + deviceId: deviceId, + channel: channel, + controller: 1, // CC#1 = Modulation Wheel + value: value + }) + .catch((err) => { + this.logger.error('[KeyboardModal] Modulation CC send failed:', err); + }); + } + + sendCC(controller, value) { + if (!this.selectedDevice || !this.backend) return; + if (this.selectedDevice.isVirtual) { + this.logger.info(`🎹 [Virtual] CC#${controller} = ${value}`); + return; } - - sendModulation(value) { - if (!this.selectedDevice || !this.backend) return; - - const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; - - if (this.selectedDevice.isVirtual) { - this.logger.info(`🎹 [Virtual] Modulation CC#1 = ${value}`); - return; - } - - const channel = this.getSelectedChannel(); - this.backend.sendCommand('midi_send_cc', { - deviceId: deviceId, - channel: channel, - controller: 1, // CC#1 = Modulation Wheel - value: value - }).catch(err => { - this.logger.error('[KeyboardModal] Modulation CC send failed:', err); - }); - } - - sendCC(controller, value) { - if (!this.selectedDevice || !this.backend) return; - if (this.selectedDevice.isVirtual) { - this.logger.info(`🎹 [Virtual] CC#${controller} = ${value}`); - return; - } - const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; - const channel = this.getSelectedChannel(); - this.backend.sendCommand('midi_send_cc', { - deviceId, channel, controller, value - }).catch(err => this.logger.error('[KeyboardModal] CC send failed:', err)); - } - - /** - * Updates the note-range display in the header - */ - _updateOctaveDisplay() { - const octaveDisplayEl = document.getElementById('keyboard-octave-display'); - if (octaveDisplayEl) { - const endNote = this.startNote + this.visibleNoteCount - 1; - const startName = this.getNoteLabel(this.startNote); - const endName = this.getNoteLabel(endNote); - octaveDisplayEl.textContent = `${startName} - ${endName}`; - } - // Keep the minimap viewport in sync with the visible range. - if (typeof this.renderMinimap === 'function') { - this.renderMinimap(); - } + const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; + const channel = this.getSelectedChannel(); + this.backend + .sendCommand('midi_send_cc', { + deviceId, + channel, + controller, + value + }) + .catch((err) => this.logger.error('[KeyboardModal] CC send failed:', err)); + } + + /** + * Updates the note-range display in the header + */ + _updateOctaveDisplay() { + const octaveDisplayEl = document.getElementById('keyboard-octave-display'); + if (octaveDisplayEl) { + const endNote = this.startNote + this.visibleNoteCount - 1; + const startName = this.getNoteLabel(this.startNote); + const endName = this.getNoteLabel(endNote); + octaveDisplayEl.textContent = `${startName} - ${endName}`; } - - /** - * Get a note's name from its MIDI number - * @param {number} noteNumber - MIDI number (0-127) - * @returns {string} - Note name (e.g. "C4", "F#5") - */ - getNoteNameFromNumber(noteNumber) { - const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; - const octave = Math.floor(noteNumber / 12) - 1; - const noteName = noteNames[noteNumber % 12]; - return `${noteName}${octave}`; - } - - /** - * Format a note label according to the user-selected note format. - * @param {number} noteNumber - MIDI number - * @returns {string} - */ - getNoteLabel(noteNumber) { - const englishNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; - const solfegeNames = ['Do', 'Do#', 'Ré', 'Ré#', 'Mi', 'Fa', 'Fa#', 'Sol', 'Sol#', 'La', 'La#', 'Si']; - const octave = Math.floor(noteNumber / 12) - 1; - const idx = noteNumber % 12; - if (this.noteLabelFormat === 'midi') { - return String(noteNumber); - } - if (this.noteLabelFormat === 'solfege') { - return `${solfegeNames[idx]}${octave}`; - } - return `${englishNames[idx]}${octave}`; - } - - /** - * Chromatic colour for a MIDI note (1 colour per pitch class, octave - * invariant). Same palette as FRET_NOTE_COLORS / LIST_NOTE_COLORS so - * every view (piano, fretboard, list AND the self-owned instrument - * views) shows consistent colours when the 🎨 toggle is on. - * @param {number} midi - * @returns {{bg:string,text:string}} - */ - getNoteColor(midi) { - const PALETTE = [ - { bg: '#EF4444', text: '#fff' }, // C - { bg: '#F4622A', text: '#fff' }, // C# - { bg: '#F97316', text: '#fff' }, // D - { bg: '#FBBF24', text: '#1a1a1a' }, // D# - { bg: '#EAB308', text: '#1a1a1a' }, // E - { bg: '#84CC16', text: '#1a1a1a' }, // F - { bg: '#22C55E', text: '#fff' }, // F# - { bg: '#14B8A6', text: '#fff' }, // G - { bg: '#06B6D4', text: '#fff' }, // G# - { bg: '#3B82F6', text: '#fff' }, // A - { bg: '#7C3AED', text: '#fff' }, // A# - { bg: '#A855F7', text: '#fff' }, // B - ]; - return PALETTE[(((midi | 0) % 12) + 12) % 12]; - } - - /** - * Note range configured for the selected instrument (same source as - * autoCenterKeyboard). Self-owned views use this instead of a hardcoded - * span so they show exactly the configured number of notes. - * - * Priority: discrete selected_notes → caps note_range_min/max → the - * modal's current visible window (startNote..+visibleNoteCount). The - * window fallback is what makes the views actually follow the - * configured note count even when an instrument declares no explicit - * range in its capabilities (QA #2). - * - * @returns {{min:number,max:number,notes:number[]|null}} - */ - getInstrumentNoteRange() { - const caps = this.selectedDeviceCapabilities; - if (caps) { - - if (caps.note_selection_mode === 'discrete' && caps.selected_notes) { - try { - const notes = typeof caps.selected_notes === 'string' - ? JSON.parse(caps.selected_notes) : caps.selected_notes; - if (Array.isArray(notes) && notes.length > 0) { - const sorted = [...notes].map(Number).filter(Number.isFinite).sort((a, b) => a - b); - if (sorted.length) { - return { min: sorted[0], max: sorted[sorted.length - 1], notes: sorted }; - } - } - } catch (_) { /* ignore */ } - } - - const min = Number(caps.note_range_min); - const max = Number(caps.note_range_max); - if (Number.isFinite(min) && Number.isFinite(max) && max >= min) { - return { min, max, notes: null }; - } - } // end if (caps) - - // Fallback: the modal's current visible window (configured octaves - // + autoCenter). Guarantees the views follow the configured note - // count instead of a per-view hardcoded span (QA #2). - const wMin = Number.isFinite(this.startNote) ? this.startNote : 48; - const count = Number.isFinite(this.visibleNoteCount) && this.visibleNoteCount > 0 - ? this.visibleNoteCount : 36; - return { min: wMin, max: wMin + count - 1, notes: null }; - } - - /** - * Instrument-specific bagpipe settings. Read from the same per-instrument - * capabilities object the instrument-settings modal already populates - * (like hands_config) — optional `caps.bagpipe_config`. Accepts the - * legacy `drones:number[]` shape and the `[{note,enabled}]` shape via - * the shared normalizer. `drones` returns only the *enabled* notes - * (what should actually sound); `droneObjs` is the full list. - * Defaults match the previous behaviour (single A2 drone, enabled). - * @returns {{drones:number[], droneObjs:Array<{note:number,enabled:boolean}>, enabled:boolean}} - */ - getBagpipeConfig() { - const caps = this.selectedDeviceCapabilities; - const c = (caps && caps.bagpipe_config) || {}; - const enabled = c.enabled !== false; - const objs = window.MidiConstants.normalizeBagpipeDrones(c.drones); - if (!objs.length) { - return { drones: [45], droneObjs: [{ note: 45, enabled: true }], enabled }; - } - const drones = objs.filter(d => d.enabled !== false).map(d => d.note); - return { drones, droneObjs: objs, enabled }; - } - - /** - * Instrument-specific accordion settings (optional `caps.accordion_config`). - * The accordion ALWAYS has both sides — this only describes the play - * possibilities of each side (no hand show/hide): - * - right side: `right_display` 'buttons' | 'keyboard' - * - left side: `bass_system` 'stradella' | 'free' (legacy 'chromatic' - * is normalized → 'free' at read time; no data migration) - * - left side span: `bass_range` {min,max} MIDI, only used by the - * free-bass system (Stradella is fixed). Defaults to C2..C4. - * Defaults preserve the previous look (Stradella bass, button right). - * @returns {{bass_system:'stradella'|'free', - * right_display:'buttons'|'keyboard', - * bass_range:{min:number,max:number}}} - */ - getAccordionConfig() { - const caps = this.selectedDeviceCapabilities; - const c = (caps && caps.accordion_config) || {}; - const rawBass = c.bass_system === 'chromatic' ? 'free' : c.bass_system; - const bass_system = ['stradella', 'free'].includes(rawBass) - ? rawBass : 'stradella'; - const right_display = ['buttons', 'keyboard'].includes(c.right_display) - ? c.right_display : 'buttons'; - // Free-bass default span C2..C4 — kept in sync with ISMSections - // _ACCORDION_BASS_DEFAULT and AccordionView FREE_BASS_LO/HI. - const note = (v, dflt) => { - const n = Number(v); - return Number.isInteger(n) && n >= 0 && n <= 127 ? n : dflt; - }; - const br = (c.bass_range && typeof c.bass_range === 'object') ? c.bass_range : {}; - let min = note(br.min, 36); - let max = note(br.max, 60); - if (min > max) { const t = min; min = max; max = t; } - // Stradella geometry (left side). Canonical function order; an - // empty/invalid selection falls back to the full 6-function board. - const ALL_FUNCS = ['counterbass', 'bass', 'major', 'minor', 'dom7', 'dim7']; - const ci = Number(c.bass_cols); - const bass_cols = Number.isInteger(ci) && ci >= 1 && ci <= 20 ? ci : 12; - const bass_base = note(c.bass_base, 36); - let bass_funcs = Array.isArray(c.bass_funcs) - ? ALL_FUNCS.filter((f) => c.bass_funcs.includes(f)) : []; - if (bass_funcs.length === 0) bass_funcs = ALL_FUNCS.slice(); - return { bass_system, right_display, bass_range: { min, max }, - bass_cols, bass_base, bass_funcs }; - } - - /** - * Instrument-specific harmonica settings (optional `caps.harmonica_config`). - * - `type`: 'diatonic' (Richter) | 'chromatic' (solo tuning + slide) - * - `key` : musical key root, one of the 12 pitch classes - * Defaults preserve the previous behaviour (diatonic, C). The chromatic - * flag lives ONLY here — `keyboard_type` is never set for a harmonica - * (that would divert GM22 to the equal-width keyboard-list view). - * @returns {{type:'diatonic'|'chromatic', key:string}} - */ - getHarmonicaConfig() { - const caps = this.selectedDeviceCapabilities; - const c = (caps && caps.harmonica_config) || {}; - const type = c.type === 'chromatic' ? 'chromatic' : 'diatonic'; - const KEYS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; - const key = KEYS.includes(c.key) ? c.key : 'C'; - return { type, key }; - } - - /** - * MIDI notes a diatonic harmonica can actually sound, for restricting - * the virtual piano (toggle to piano view on a harmonica). A real - * diatonic harp only sounds the exact Richter blow/draw pitches of its - * key — so the piano greys out every other key (all black keys in C). - * Returns null when there is no restriction (not a harmonica, or a - * chromatic harmonica — its slide reaches every semitone). - * @returns {Set|null} - */ - getHarmonicaPlayableNotes() { - const caps = this.selectedDeviceCapabilities; - const gmProgram = caps?.gm_program ?? this.selectedDevice?.gm_program ?? null; - if (gmProgram !== 22) return null; - const cfg = this.getHarmonicaConfig(); - if (cfg.type !== 'diatonic') return null; - const HL = typeof window !== 'undefined' ? window.HarmonicaLayout : null; - if (!HL || typeof HL.playableSet !== 'function') return null; - return HL.playableSet(cfg, this.getInstrumentNoteRange()); - } - - /** - * Detect whether the selected instrument should switch to a special view. - * @returns {{ canFretboard: boolean, isDrum: boolean, instrumentType: string }} - */ - getInstrumentViewInfo() { - // Delegates to the pure InstrumentDetector module (Phase B). - // Callers continue to receive the legacy { canFretboard, isBowed, - // isDrum, isWind, windPreset, instrumentType, instrumentSubtype, - // gmProgram } shape. `viewKind` is additionally exposed for the - // upcoming InstrumentView registry (Phase C+). - const detector = (typeof window !== 'undefined' && window.InstrumentDetector) || null; - if (!detector) { - // Defensive fallback — should never happen in production since - // InstrumentDetector.js is loaded before KeyboardModal.js. - return { - viewKind: 'piano', canFretboard: false, isBowed: false, - isDrum: false, isWind: false, windPreset: null, - instrumentType: 'unknown', instrumentSubtype: '', gmProgram: undefined - }; - } - return detector.detect({ - capabilities: this.selectedDeviceCapabilities, - selectedDevice: this.selectedDevice, - stringInstrumentConfig: this.stringInstrumentConfig, - windDb: typeof WindInstrumentDatabase !== 'undefined' ? WindInstrumentDatabase : null - }); - } - - /** - * Whether the currently selected instrument is a bowed-string family - * (violin, viola, cello, contrabass, tremolo, pizzicato, fiddle). - * Used by the fretboard renderer + chord bar to swap pluck/strum - * controls for the continuous-bow controls. - * @returns {boolean} - */ - _isBowedInstrument() { - if (typeof this.getInstrumentViewInfo !== 'function') return false; - const info = this.getInstrumentViewInfo(); - return info && info.isBowed === true; - } - - /** - * Refresh the latency display in the header from the selected instrument. - */ - updateLatencyDisplay() { - const el = document.getElementById('keyboard-latency-display'); - if (!el) return; - const caps = this.selectedDeviceCapabilities; - const delay = caps && typeof caps.sync_delay === 'number' ? caps.sync_delay : null; - if (delay === null || !this.selectedDevice) { - el.classList.add('latency-empty'); - el.textContent = '—'; - } else { - el.classList.remove('latency-empty'); - const sign = delay > 0 ? '+' : ''; - el.textContent = `${sign}${delay} ms`; - } + // Keep the minimap viewport in sync with the visible range. + if (typeof this.renderMinimap === 'function') { + this.renderMinimap(); } - - /** - * GM-program → default string-instrument geometry (num_strings, num_frets, - * tuning in MIDI). Used as a fallback when the database has no per-channel - * string-instrument config for the selected device. - */ - _getStringPresetForGmProgram(gmProgram) { - if (gmProgram === undefined || gmProgram === null) return null; - // Acoustic Guitar (nylon) - if (gmProgram === 24) return { num_strings: 6, num_frets: 19, tuning: [40, 45, 50, 55, 59, 64], is_fretless: false }; - // Acoustic Guitar (steel) - if (gmProgram === 25) return { num_strings: 6, num_frets: 20, tuning: [40, 45, 50, 55, 59, 64], is_fretless: false }; - // Electric guitars (jazz, clean, muted, overdriven, distortion, harmonics) - if (gmProgram >= 26 && gmProgram <= 31) return { num_strings: 6, num_frets: 22, tuning: [40, 45, 50, 55, 59, 64], is_fretless: false }; - // Acoustic Bass - if (gmProgram === 32) return { num_strings: 4, num_frets: 20, tuning: [28, 33, 38, 43], is_fretless: false }; - // Electric basses (finger, pick, fretless, slap×2, synth×2) - if (gmProgram === 35) return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true }; - if (gmProgram >= 33 && gmProgram <= 39) return { num_strings: 4, num_frets: 22, tuning: [28, 33, 38, 43], is_fretless: false }; - // Orchestral strings (violin, viola, cello, contrabass, tremolo, pizzicato, ensemble1, ensemble2) - if (gmProgram === 40 || gmProgram === 110) return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // violin / fiddle - if (gmProgram === 41) return { num_strings: 4, num_frets: 0, tuning: [48, 55, 62, 69], is_fretless: true }; // viola - if (gmProgram === 42) return { num_strings: 4, num_frets: 0, tuning: [36, 43, 50, 57], is_fretless: true }; // cello - if (gmProgram === 43) return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true }; // contrabass - if (gmProgram >= 44 && gmProgram <= 45) return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // tremolo / pizzicato (default to violin) - // Harp (special: many strings, no frets). 47-string concert harp: - // C-major diatonic from C1 (MIDI 24) up to G7 (MIDI 103). Matches - // the "Harpe" preset (StringInstrumentPresets.js → num_strings: 47). - if (gmProgram === 46) { - const STEP = [2, 2, 1, 2, 2, 2, 1]; // C-major intervals - const tuning = []; - let n = 24; - for (let i = 0; i < 47; i++) { - tuning.push(n); - n += STEP[i % 7]; - } - return { num_strings: 47, num_frets: 0, tuning, is_fretless: true }; - } - // Timpani — not a string instrument; fall through. - // Sitar - if (gmProgram === 104) return { num_strings: 7, num_frets: 20, tuning: [36, 43, 50, 55, 62, 69, 76], is_fretless: false }; - // Banjo - if (gmProgram === 105) return { num_strings: 5, num_frets: 22, tuning: [62, 67, 50, 55, 50], is_fretless: false }; - // Shamisen (3 strings) - if (gmProgram === 106) return { num_strings: 3, num_frets: 0, tuning: [50, 57, 62], is_fretless: true }; - // Koto (13 strings, traditional tuning approximate) - if (gmProgram === 107) return { num_strings: 13, num_frets: 0, tuning: null, is_fretless: true }; - return null; - } - - /** - * Try to load string-instrument config (num_strings, num_frets, tuning) for - * the selected instrument. Falls back to a GM-program preset when the - * database has no per-channel config. - */ - async loadStringInstrumentConfig() { - this.stringInstrumentConfig = null; - if (!this.selectedDevice) return; - const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; - const channel = this.getSelectedChannel(); + } + + /** + * Get a note's name from its MIDI number + * @param {number} noteNumber - MIDI number (0-127) + * @returns {string} - Note name (e.g. "C4", "F#5") + */ + getNoteNameFromNumber(noteNumber) { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const octave = Math.floor(noteNumber / 12) - 1; + const noteName = noteNames[noteNumber % 12]; + return `${noteName}${octave}`; + } + + /** + * Format a note label according to the user-selected note format. + * @param {number} noteNumber - MIDI number + * @returns {string} + */ + getNoteLabel(noteNumber) { + const englishNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const solfegeNames = [ + 'Do', + 'Do#', + 'Ré', + 'Ré#', + 'Mi', + 'Fa', + 'Fa#', + 'Sol', + 'Sol#', + 'La', + 'La#', + 'Si' + ]; + const octave = Math.floor(noteNumber / 12) - 1; + const idx = noteNumber % 12; + if (this.noteLabelFormat === 'midi') { + return String(noteNumber); + } + if (this.noteLabelFormat === 'solfege') { + return `${solfegeNames[idx]}${octave}`; + } + return `${englishNames[idx]}${octave}`; + } + + /** + * Chromatic colour for a MIDI note (1 colour per pitch class, octave + * invariant). Same palette as FRET_NOTE_COLORS / LIST_NOTE_COLORS so + * every view (piano, fretboard, list AND the self-owned instrument + * views) shows consistent colours when the 🎨 toggle is on. + * @param {number} midi + * @returns {{bg:string,text:string}} + */ + getNoteColor(midi) { + const PALETTE = [ + { bg: '#EF4444', text: '#fff' }, // C + { bg: '#F4622A', text: '#fff' }, // C# + { bg: '#F97316', text: '#fff' }, // D + { bg: '#FBBF24', text: '#1a1a1a' }, // D# + { bg: '#EAB308', text: '#1a1a1a' }, // E + { bg: '#84CC16', text: '#1a1a1a' }, // F + { bg: '#22C55E', text: '#fff' }, // F# + { bg: '#14B8A6', text: '#fff' }, // G + { bg: '#06B6D4', text: '#fff' }, // G# + { bg: '#3B82F6', text: '#fff' }, // A + { bg: '#7C3AED', text: '#fff' }, // A# + { bg: '#A855F7', text: '#fff' } // B + ]; + return PALETTE[(((midi | 0) % 12) + 12) % 12]; + } + + /** + * Note range configured for the selected instrument (same source as + * autoCenterKeyboard). Self-owned views use this instead of a hardcoded + * span so they show exactly the configured number of notes. + * + * Priority: discrete selected_notes → caps note_range_min/max → the + * modal's current visible window (startNote..+visibleNoteCount). The + * window fallback is what makes the views actually follow the + * configured note count even when an instrument declares no explicit + * range in its capabilities (QA #2). + * + * @returns {{min:number,max:number,notes:number[]|null}} + */ + getInstrumentNoteRange() { + const caps = this.selectedDeviceCapabilities; + if (caps) { + if (caps.note_selection_mode === 'discrete' && caps.selected_notes) { try { - const resp = await this.backend.sendCommand('string_instrument_get', { - device_id: deviceId, - channel: channel - }); - if (resp && resp.instrument) { - this.stringInstrumentConfig = resp.instrument; - this._mergeHandsConfigFromCapabilities(); - if (typeof this._updateSlideModeGroupVisibility === 'function') { - this._updateSlideModeGroupVisibility(); - } - return; + const notes = + typeof caps.selected_notes === 'string' + ? JSON.parse(caps.selected_notes) + : caps.selected_notes; + if (Array.isArray(notes) && notes.length > 0) { + const sorted = [...notes] + .map(Number) + .filter(Number.isFinite) + .sort((a, b) => a - b); + if (sorted.length) { + return { min: sorted[0], max: sorted[sorted.length - 1], notes: sorted }; } - } catch (e) { /* ignore — fallback below */ } - - // Fallback: GM-program-based defaults bundled in the frontend. - const caps = this.selectedDeviceCapabilities; - const gmProgram = (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program); - const preset = this._getStringPresetForGmProgram(gmProgram); - if (preset) { - this.stringInstrumentConfig = preset; - } + } + } catch (_) { + /* ignore */ + } + } + + const min = Number(caps.note_range_min); + const max = Number(caps.note_range_max); + if (Number.isFinite(min) && Number.isFinite(max) && max >= min) { + return { min, max, notes: null }; + } + } // end if (caps) + + // Fallback: the modal's current visible window (configured octaves + // + autoCenter). Guarantees the views follow the configured note + // count instead of a per-view hardcoded span (QA #2). + const wMin = Number.isFinite(this.startNote) ? this.startNote : 48; + const count = + Number.isFinite(this.visibleNoteCount) && this.visibleNoteCount > 0 + ? this.visibleNoteCount + : 36; + return { min: wMin, max: wMin + count - 1, notes: null }; + } + + /** + * Instrument-specific bagpipe settings. Read from the same per-instrument + * capabilities object the instrument-settings modal already populates + * (like hands_config) — optional `caps.bagpipe_config`. Accepts the + * legacy `drones:number[]` shape and the `[{note,enabled}]` shape via + * the shared normalizer. `drones` returns only the *enabled* notes + * (what should actually sound); `droneObjs` is the full list. + * Defaults match the previous behaviour (single A2 drone, enabled). + * @returns {{drones:number[], droneObjs:Array<{note:number,enabled:boolean}>, enabled:boolean}} + */ + getBagpipeConfig() { + const caps = this.selectedDeviceCapabilities; + const c = (caps && caps.bagpipe_config) || {}; + const enabled = c.enabled !== false; + const objs = window.MidiConstants.normalizeBagpipeDrones(c.drones); + if (!objs.length) { + return { drones: [45], droneObjs: [{ note: 45, enabled: true }], enabled }; + } + const drones = objs.filter((d) => d.enabled !== false).map((d) => d.note); + return { drones, droneObjs: objs, enabled }; + } + + /** + * Instrument-specific accordion settings (optional `caps.accordion_config`). + * The accordion ALWAYS has both sides — this only describes the play + * possibilities of each side (no hand show/hide): + * - right side: `right_display` 'buttons' | 'keyboard' + * - left side: `bass_system` 'stradella' | 'free' (legacy 'chromatic' + * is normalized → 'free' at read time; no data migration) + * - left side span: `bass_range` {min,max} MIDI, only used by the + * free-bass system (Stradella is fixed). Defaults to C2..C4. + * Defaults preserve the previous look (Stradella bass, button right). + * @returns {{bass_system:'stradella'|'free', + * right_display:'buttons'|'keyboard', + * bass_range:{min:number,max:number}}} + */ + getAccordionConfig() { + const caps = this.selectedDeviceCapabilities; + const c = (caps && caps.accordion_config) || {}; + const rawBass = c.bass_system === 'chromatic' ? 'free' : c.bass_system; + const bass_system = ['stradella', 'free'].includes(rawBass) ? rawBass : 'stradella'; + const right_display = ['buttons', 'keyboard'].includes(c.right_display) + ? c.right_display + : 'buttons'; + // Free-bass default span C2..C4 — kept in sync with ISMSections + // _ACCORDION_BASS_DEFAULT and AccordionView FREE_BASS_LO/HI. + const note = (v, dflt) => { + const n = Number(v); + return Number.isInteger(n) && n >= 0 && n <= 127 ? n : dflt; + }; + const br = c.bass_range && typeof c.bass_range === 'object' ? c.bass_range : {}; + let min = note(br.min, 36); + let max = note(br.max, 60); + if (min > max) { + const t = min; + min = max; + max = t; + } + // Stradella geometry (left side). Canonical function order; an + // empty/invalid selection falls back to the full 6-function board. + const ALL_FUNCS = ['counterbass', 'bass', 'major', 'minor', 'dom7', 'dim7']; + const ci = Number(c.bass_cols); + const bass_cols = Number.isInteger(ci) && ci >= 1 && ci <= 20 ? ci : 12; + const bass_base = note(c.bass_base, 36); + let bass_funcs = Array.isArray(c.bass_funcs) + ? ALL_FUNCS.filter((f) => c.bass_funcs.includes(f)) + : []; + if (bass_funcs.length === 0) bass_funcs = ALL_FUNCS.slice(); + return { + bass_system, + right_display, + bass_range: { min, max }, + bass_cols, + bass_base, + bass_funcs + }; + } + + /** + * Instrument-specific harmonica settings (optional `caps.harmonica_config`). + * - `type`: 'diatonic' (Richter) | 'chromatic' (solo tuning + slide) + * - `key` : musical key root, one of the 12 pitch classes + * Defaults preserve the previous behaviour (diatonic, C). The chromatic + * flag lives ONLY here — `keyboard_type` is never set for a harmonica + * (that would divert GM22 to the equal-width keyboard-list view). + * @returns {{type:'diatonic'|'chromatic', key:string}} + */ + getHarmonicaConfig() { + const caps = this.selectedDeviceCapabilities; + const c = (caps && caps.harmonica_config) || {}; + const type = c.type === 'chromatic' ? 'chromatic' : 'diatonic'; + const KEYS = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + const key = KEYS.includes(c.key) ? c.key : 'C'; + return { type, key }; + } + + /** + * MIDI notes a diatonic harmonica can actually sound, for restricting + * the virtual piano (toggle to piano view on a harmonica). A real + * diatonic harp only sounds the exact Richter blow/draw pitches of its + * key — so the piano greys out every other key (all black keys in C). + * Returns null when there is no restriction (not a harmonica, or a + * chromatic harmonica — its slide reaches every semitone). + * @returns {Set|null} + */ + getHarmonicaPlayableNotes() { + const caps = this.selectedDeviceCapabilities; + const gmProgram = caps?.gm_program ?? this.selectedDevice?.gm_program ?? null; + if (gmProgram !== 22) return null; + const cfg = this.getHarmonicaConfig(); + if (cfg.type !== 'diatonic') return null; + const HL = typeof window !== 'undefined' ? window.HarmonicaLayout : null; + if (!HL || typeof HL.playableSet !== 'function') return null; + return HL.playableSet(cfg, this.getInstrumentNoteRange()); + } + + /** + * Detect whether the selected instrument should switch to a special view. + * @returns {{ canFretboard: boolean, isDrum: boolean, instrumentType: string }} + */ + getInstrumentViewInfo() { + // Delegates to the pure InstrumentDetector module (Phase B). + // Callers continue to receive the legacy { canFretboard, isBowed, + // isDrum, isWind, windPreset, instrumentType, instrumentSubtype, + // gmProgram } shape. `viewKind` is additionally exposed for the + // upcoming InstrumentView registry (Phase C+). + const detector = (typeof window !== 'undefined' && window.InstrumentDetector) || null; + if (!detector) { + // Defensive fallback — should never happen in production since + // InstrumentDetector.js is loaded before KeyboardModal.js. + return { + viewKind: 'piano', + canFretboard: false, + isBowed: false, + isDrum: false, + isWind: false, + windPreset: null, + instrumentType: 'unknown', + instrumentSubtype: '', + gmProgram: undefined + }; + } + return detector.detect({ + capabilities: this.selectedDeviceCapabilities, + selectedDevice: this.selectedDevice, + stringInstrumentConfig: this.stringInstrumentConfig, + windDb: typeof WindInstrumentDatabase !== 'undefined' ? WindInstrumentDatabase : null + }); + } + + /** + * Whether the currently selected instrument is a bowed-string family + * (violin, viola, cello, contrabass, tremolo, pizzicato, fiddle). + * Used by the fretboard renderer + chord bar to swap pluck/strum + * controls for the continuous-bow controls. + * @returns {boolean} + */ + _isBowedInstrument() { + if (typeof this.getInstrumentViewInfo !== 'function') return false; + const info = this.getInstrumentViewInfo(); + return info && info.isBowed === true; + } + + /** + * Refresh the latency display in the header from the selected instrument. + */ + updateLatencyDisplay() { + const el = document.getElementById('keyboard-latency-display'); + if (!el) return; + const caps = this.selectedDeviceCapabilities; + const delay = caps && typeof caps.sync_delay === 'number' ? caps.sync_delay : null; + if (delay === null || !this.selectedDevice) { + el.classList.add('latency-empty'); + el.textContent = '—'; + } else { + el.classList.remove('latency-empty'); + const sign = delay > 0 ? '+' : ''; + el.textContent = `${sign}${delay} ms`; + } + } + + /** + * GM-program → default string-instrument geometry (num_strings, num_frets, + * tuning in MIDI). Used as a fallback when the database has no per-channel + * string-instrument config for the selected device. + */ + _getStringPresetForGmProgram(gmProgram) { + if (gmProgram === undefined || gmProgram === null) return null; + // Acoustic Guitar (nylon) + if (gmProgram === 24) + return { + num_strings: 6, + num_frets: 19, + tuning: [40, 45, 50, 55, 59, 64], + is_fretless: false + }; + // Acoustic Guitar (steel) + if (gmProgram === 25) + return { + num_strings: 6, + num_frets: 20, + tuning: [40, 45, 50, 55, 59, 64], + is_fretless: false + }; + // Electric guitars (jazz, clean, muted, overdriven, distortion, harmonics) + if (gmProgram >= 26 && gmProgram <= 31) + return { + num_strings: 6, + num_frets: 22, + tuning: [40, 45, 50, 55, 59, 64], + is_fretless: false + }; + // Acoustic Bass + if (gmProgram === 32) + return { num_strings: 4, num_frets: 20, tuning: [28, 33, 38, 43], is_fretless: false }; + // Electric basses (finger, pick, fretless, slap×2, synth×2) + if (gmProgram === 35) + return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true }; + if (gmProgram >= 33 && gmProgram <= 39) + return { num_strings: 4, num_frets: 22, tuning: [28, 33, 38, 43], is_fretless: false }; + // Orchestral strings (violin, viola, cello, contrabass, tremolo, pizzicato, ensemble1, ensemble2) + if (gmProgram === 40 || gmProgram === 110) + return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // violin / fiddle + if (gmProgram === 41) + return { num_strings: 4, num_frets: 0, tuning: [48, 55, 62, 69], is_fretless: true }; // viola + if (gmProgram === 42) + return { num_strings: 4, num_frets: 0, tuning: [36, 43, 50, 57], is_fretless: true }; // cello + if (gmProgram === 43) + return { num_strings: 4, num_frets: 0, tuning: [28, 33, 38, 43], is_fretless: true }; // contrabass + if (gmProgram >= 44 && gmProgram <= 45) + return { num_strings: 4, num_frets: 0, tuning: [55, 62, 69, 76], is_fretless: true }; // tremolo / pizzicato (default to violin) + // Harp (special: many strings, no frets). 47-string concert harp: + // C-major diatonic from C1 (MIDI 24) up to G7 (MIDI 103). Matches + // the "Harpe" preset (StringInstrumentPresets.js → num_strings: 47). + if (gmProgram === 46) { + const STEP = [2, 2, 1, 2, 2, 2, 1]; // C-major intervals + const tuning = []; + let n = 24; + for (let i = 0; i < 47; i++) { + tuning.push(n); + n += STEP[i % 7]; + } + return { num_strings: 47, num_frets: 0, tuning, is_fretless: true }; + } + // Timpani — not a string instrument; fall through. + // Sitar + if (gmProgram === 104) + return { + num_strings: 7, + num_frets: 20, + tuning: [36, 43, 50, 55, 62, 69, 76], + is_fretless: false + }; + // Banjo + if (gmProgram === 105) + return { num_strings: 5, num_frets: 22, tuning: [62, 67, 50, 55, 50], is_fretless: false }; + // Shamisen (3 strings) + if (gmProgram === 106) + return { num_strings: 3, num_frets: 0, tuning: [50, 57, 62], is_fretless: true }; + // Koto (13 strings, traditional tuning approximate) + if (gmProgram === 107) + return { num_strings: 13, num_frets: 0, tuning: null, is_fretless: true }; + return null; + } + + /** + * Try to load string-instrument config (num_strings, num_frets, tuning) for + * the selected instrument. Falls back to a GM-program preset when the + * database has no per-channel config. + */ + async loadStringInstrumentConfig() { + this.stringInstrumentConfig = null; + if (!this.selectedDevice) return; + const deviceId = this.selectedDevice.device_id || this.selectedDevice.id; + const channel = this.getSelectedChannel(); + try { + const resp = await this.backend.sendCommand('string_instrument_get', { + device_id: deviceId, + channel: channel + }); + if (resp && resp.instrument) { + this.stringInstrumentConfig = resp.instrument; this._mergeHandsConfigFromCapabilities(); if (typeof this._updateSlideModeGroupVisibility === 'function') { - this._updateSlideModeGroupVisibility(); + this._updateSlideModeGroupVisibility(); } + return; + } + } catch (e) { + /* ignore — fallback below */ } - /** - * Last-resort string set for the dedicated HarpView. A harp (GM 46) is - * NOT a "string" instrument in the settings modal — it has no - * strings/tuning tab; its playable notes come from the Notes section - * (note range / discrete selection) which HarpView reads via - * getInstrumentNoteRange(). This preset is only used when no range is - * available at all. Kept off this.stringInstrumentConfig so the - * InstrumentDetector escape hatch (a present stringCfg forces the - * fretboard view for GM 46) stays untouched. - * @returns {{tuning:number[], num_strings:number}|null} - */ - _harpPresetStringConfig() { - const caps = this.selectedDeviceCapabilities; - const gmProgram = (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program); - const preset = this._getStringPresetForGmProgram(gmProgram); - if (preset && Array.isArray(preset.tuning) && preset.tuning.length) { - return { tuning: preset.tuning, num_strings: preset.num_strings }; - } - return null; - } - - /** - * Merge hands_config from selectedDeviceCapabilities into stringInstrumentConfig. - * This is needed because hands_config is saved via the instrument settings modal - * (instrument_save_all) but stringInstrumentConfig is loaded from string_instrument_get. - */ - _mergeHandsConfigFromCapabilities() { - if (!this.stringInstrumentConfig) return; - const caps = this.selectedDeviceCapabilities; - if (caps && caps.hands_config) { - this.stringInstrumentConfig.hands_config = caps.hands_config; - } + // Fallback: GM-program-based defaults bundled in the frontend. + const caps = this.selectedDeviceCapabilities; + const gmProgram = + (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program); + const preset = this._getStringPresetForGmProgram(gmProgram); + if (preset) { + this.stringInstrumentConfig = preset; } - - populateDeviceSelect() { - this._buildInstrumentDropdown(); - this._updateInstrumentTrigger(); + this._mergeHandsConfigFromCapabilities(); + if (typeof this._updateSlideModeGroupVisibility === 'function') { + this._updateSlideModeGroupVisibility(); } - - _buildInstrumentDropdown() { - const dropdown = document.getElementById('instrument-dropdown'); - if (!dropdown) return; - - // Build off-DOM with a DocumentFragment so we attach ~138 nodes - // in one paint, then swap children atomically. - const frag = document.createDocumentFragment(); - - // "No selection" entry — spans full grid width - const noneBtn = document.createElement('button'); - noneBtn.type = 'button'; - noneBtn.className = 'instrument-option option-none' + (!this.selectedDevice ? ' selected' : ''); - noneBtn.dataset.deviceId = ''; - noneBtn.innerHTML = ` + } + + /** + * Last-resort string set for the dedicated HarpView. A harp (GM 46) is + * NOT a "string" instrument in the settings modal — it has no + * strings/tuning tab; its playable notes come from the Notes section + * (note range / discrete selection) which HarpView reads via + * getInstrumentNoteRange(). This preset is only used when no range is + * available at all. Kept off this.stringInstrumentConfig so the + * InstrumentDetector escape hatch (a present stringCfg forces the + * fretboard view for GM 46) stays untouched. + * @returns {{tuning:number[], num_strings:number}|null} + */ + _harpPresetStringConfig() { + const caps = this.selectedDeviceCapabilities; + const gmProgram = + (caps && caps.gm_program) ?? (this.selectedDevice && this.selectedDevice.gm_program); + const preset = this._getStringPresetForGmProgram(gmProgram); + if (preset && Array.isArray(preset.tuning) && preset.tuning.length) { + return { tuning: preset.tuning, num_strings: preset.num_strings }; + } + return null; + } + + /** + * Merge hands_config from selectedDeviceCapabilities into stringInstrumentConfig. + * This is needed because hands_config is saved via the instrument settings modal + * (instrument_save_all) but stringInstrumentConfig is loaded from string_instrument_get. + */ + _mergeHandsConfigFromCapabilities() { + if (!this.stringInstrumentConfig) return; + const caps = this.selectedDeviceCapabilities; + if (caps && caps.hands_config) { + this.stringInstrumentConfig.hands_config = caps.hands_config; + } + } + + populateDeviceSelect() { + this._buildInstrumentDropdown(); + this._updateInstrumentTrigger(); + } + + _buildInstrumentDropdown() { + const dropdown = document.getElementById('instrument-dropdown'); + if (!dropdown) return; + + // Build off-DOM with a DocumentFragment so we attach ~138 nodes + // in one paint, then swap children atomically. + const frag = document.createDocumentFragment(); + + // "No selection" entry — spans full grid width + const noneBtn = document.createElement('button'); + noneBtn.type = 'button'; + noneBtn.className = 'instrument-option option-none' + (!this.selectedDevice ? ' selected' : ''); + noneBtn.dataset.deviceId = ''; + noneBtn.innerHTML = `
🎵
— ${this.t('common.select')} — `; - frag.appendChild(noneBtn); - - this.devices.forEach(device => { - const deviceId = device.device_id || device.id; - const rawValue = device._multiInstrument ? `${deviceId}::${device.channel}` : deviceId; - const gmProgram = device.gm_program; - const channel = device.channel; - - const icon = window.InstrumentFamilies - ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel }) - : { svgUrl: null, emoji: '🎵' }; - - const isSelected = this.selectedDevice - && (this.selectedDevice.device_id === deviceId || this.selectedDevice.id === deviceId) - && (!device._multiInstrument || device.channel === this.selectedDevice.channel); - - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'instrument-option' + (isSelected ? ' selected' : ''); - btn.dataset.deviceId = rawValue; - - const name = device.displayName || device.name; - const chLabel = device._multiInstrument - ? `Ch${(channel || 0) + 1}` - : ''; - - const imgHtml = icon.svgUrl - ? `` - : `${icon.emoji}`; - - // `name` is a user-configurable device label → never interpolate - // it into innerHTML. The icon markup and chLabel are built from - // internal/numeric data only, so they stay as markup. - btn.innerHTML = `
${imgHtml}
`; - const nameSpan = btn.querySelector('.option-name'); - nameSpan.textContent = name; - if (chLabel) nameSpan.insertAdjacentHTML('beforeend', chLabel); - frag.appendChild(btn); - }); - - dropdown.replaceChildren(frag); - - // Event delegation: a single listener on the container, instead of - // one per button. Guard on the element itself (not an instance flag) - // so each freshly created #instrument-dropdown gets its own listener; - // a stale instance flag would skip rebinding after close()/mountAsPanel(). - if (!dropdown._instrumentClickBound) { - dropdown.addEventListener('click', (e) => { - const opt = e.target.closest('.instrument-option'); - if (!opt || !dropdown.contains(opt)) return; - const rawValue = opt.dataset.deviceId ?? ''; - this._selectInstrumentOption(rawValue); - }); - dropdown._instrumentClickBound = true; - } + frag.appendChild(noneBtn); + + this.devices.forEach((device) => { + const deviceId = device.device_id || device.id; + const rawValue = device._multiInstrument ? `${deviceId}::${device.channel}` : deviceId; + const gmProgram = device.gm_program; + const channel = device.channel; + + const icon = window.InstrumentFamilies + ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel }) + : { svgUrl: null, emoji: '🎵' }; + + const isSelected = + this.selectedDevice && + (this.selectedDevice.device_id === deviceId || this.selectedDevice.id === deviceId) && + (!device._multiInstrument || device.channel === this.selectedDevice.channel); + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'instrument-option' + (isSelected ? ' selected' : ''); + btn.dataset.deviceId = rawValue; + + const name = device.displayName || device.name; + const chLabel = device._multiInstrument + ? `Ch${(channel || 0) + 1}` + : ''; + + const imgHtml = icon.svgUrl + ? `` + : `${icon.emoji}`; + + // `name` is a user-configurable device label → never interpolate + // it into innerHTML. The icon markup and chLabel are built from + // internal/numeric data only, so they stay as markup. + btn.innerHTML = `
${imgHtml}
`; + const nameSpan = btn.querySelector('.option-name'); + nameSpan.textContent = name; + if (chLabel) nameSpan.insertAdjacentHTML('beforeend', chLabel); + frag.appendChild(btn); + }); + + dropdown.replaceChildren(frag); + + // Event delegation: a single listener on the container, instead of + // one per button. Guard on the element itself (not an instance flag) + // so each freshly created #instrument-dropdown gets its own listener; + // a stale instance flag would skip rebinding after close()/mountAsPanel(). + if (!dropdown._instrumentClickBound) { + dropdown.addEventListener('click', (e) => { + const opt = e.target.closest('.instrument-option'); + if (!opt || !dropdown.contains(opt)) return; + const rawValue = opt.dataset.deviceId ?? ''; + this._selectInstrumentOption(rawValue); + }); + dropdown._instrumentClickBound = true; } - - // Adapt the instrument dropdown to the number of instruments: widen it - // (more columns, up to the screen edge) then grow its height to the - // viewport so everything shows with as little scrolling as possible. - // CSS `auto-fill` can't pick a column count proportional to a dynamic - // item count without a definite width, so we compute it here on open - // and clear the inline sizing on close (the dropdown is reused across - // the standalone modal and the loop-editor panel mount). - _sizeInstrumentDropdown(isOpen) { - const dropdown = document.getElementById('instrument-dropdown'); - if (!dropdown) return; - - if (!isOpen) { - dropdown.style.width = ''; - dropdown.style.maxHeight = ''; - dropdown.style.gridTemplateColumns = ''; - return; - } - - const trigger = document.getElementById('instrument-trigger'); - const cell = dropdown.querySelector('.instrument-option:not(.option-none)'); - if (!trigger || !cell) return; - - const cs = getComputedStyle(dropdown); - const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); - const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); - const gap = parseFloat(cs.rowGap || cs.gap) || 2; - - const itemW = cell.offsetWidth || 78; - const rowH = cell.offsetHeight || 64; - - const noneEl = dropdown.querySelector('.option-none'); - let noneH = 0; - if (noneEl) { - const nb = parseFloat(getComputedStyle(noneEl).marginBottom) || 0; - noneH = noneEl.offsetHeight + nb + gap; - } - - const nData = dropdown.querySelectorAll('.instrument-option:not(.option-none)').length; - if (nData === 0) { - dropdown.style.width = ''; - dropdown.style.maxHeight = ''; - dropdown.style.gridTemplateColumns = ''; - return; - } - - const rect = trigger.getBoundingClientRect(); - const panelMode = !!dropdown.closest('.modal-dialog.km-panel-mode'); - - let availW = Math.min(window.innerWidth - 12, window.innerWidth - rect.left - 8); - availW = Math.max(320, availW); - const availH = Math.max( - 160, - panelMode ? rect.top - 12 : window.innerHeight - rect.bottom - 12 - ); - - const colsForW = (w) => Math.max(1, Math.floor((w - padX + gap) / (itemW + gap))); - // Compact baseline = the column count that fills the 320px min-width - // (≈ today's look); never go below it so the few-instrument case is - // unchanged. Cap = how many columns fit out to the screen edge. - const minCols = colsForW(320); - const maxCols = Math.max(minCols, colsForW(availW)); - - const heightFor = (cols) => { - const rows = Math.ceil(nData / cols); - return padY + noneH + rows * rowH + (rows - 1) * gap; - }; - - // Stay compact when everything already fits; otherwise widen just - // enough to fit the viewport height. If even the widest grid can't - // fit, use the widest one (scroll minimized "au max"). - let cols = maxCols; - for (let c = minCols; c <= maxCols; c++) { - if (heightFor(c) <= availH) { cols = c; break; } - } - - const width = Math.min( - availW, - Math.max(320, cols * itemW + (cols - 1) * gap + padX) - ); - - dropdown.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; - dropdown.style.width = `${Math.round(width)}px`; - dropdown.style.maxHeight = `${Math.round(availH)}px`; + } + + // Adapt the instrument dropdown to the number of instruments: widen it + // (more columns, up to the screen edge) then grow its height to the + // viewport so everything shows with as little scrolling as possible. + // CSS `auto-fill` can't pick a column count proportional to a dynamic + // item count without a definite width, so we compute it here on open + // and clear the inline sizing on close (the dropdown is reused across + // the standalone modal and the loop-editor panel mount). + _sizeInstrumentDropdown(isOpen) { + const dropdown = document.getElementById('instrument-dropdown'); + if (!dropdown) return; + + if (!isOpen) { + dropdown.style.width = ''; + dropdown.style.maxHeight = ''; + dropdown.style.gridTemplateColumns = ''; + return; } - _updateInstrumentTrigger() { - const triggerSvg = document.getElementById('instrument-trigger-svg'); - const triggerEmoji = document.getElementById('instrument-trigger-emoji'); - const triggerName = document.getElementById('instrument-trigger-name'); - if (!triggerName) return; + const trigger = document.getElementById('instrument-trigger'); + const cell = dropdown.querySelector('.instrument-option:not(.option-none)'); + if (!trigger || !cell) return; - if (!this.selectedDevice) { - if (triggerSvg) triggerSvg.style.display = 'none'; - if (triggerEmoji) { triggerEmoji.textContent = '🎵'; triggerEmoji.style.display = 'inline'; } - triggerName.textContent = `— ${this.t('common.select')} —`; - return; - } + const cs = getComputedStyle(dropdown); + const padX = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0); + const padY = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); + const gap = parseFloat(cs.rowGap || cs.gap) || 2; - const gmProgram = this.selectedDevice.gm_program; - const channel = this.selectedDevice.channel; - const icon = window.InstrumentFamilies - ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel }) - : { svgUrl: null, emoji: '🎵' }; - - if (icon.svgUrl && triggerSvg) { - triggerSvg.src = icon.svgUrl; - triggerSvg.style.display = 'block'; - triggerSvg.onerror = () => { - triggerSvg.style.display = 'none'; - if (triggerEmoji) { triggerEmoji.textContent = icon.emoji; triggerEmoji.style.display = 'inline'; } - }; - if (triggerEmoji) triggerEmoji.style.display = 'none'; - } else { - if (triggerSvg) triggerSvg.style.display = 'none'; - if (triggerEmoji) { triggerEmoji.textContent = icon.emoji; triggerEmoji.style.display = 'inline'; } - } + const itemW = cell.offsetWidth || 78; + const rowH = cell.offsetHeight || 64; - triggerName.textContent = this.selectedDevice.displayName || this.selectedDevice.name; + const noneEl = dropdown.querySelector('.option-none'); + let noneH = 0; + if (noneEl) { + const nb = parseFloat(getComputedStyle(noneEl).marginBottom) || 0; + noneH = noneEl.offsetHeight + nb + gap; } - async _selectInstrumentOption(rawValue) { - // Close dropdown - const dropdown = document.getElementById('instrument-dropdown'); - const selector = document.getElementById('header-instrument-selector'); - const trigger = document.getElementById('instrument-trigger'); - dropdown?.classList.remove('open'); - selector?.classList.remove('open'); - if (trigger) trigger.setAttribute('aria-expanded', 'false'); - this._sizeInstrumentDropdown(false); - - // Stop any held bow before swapping instruments — the new chord bar - // about to be rendered won't reuse the previous button reference, and - // the chord notes would otherwise keep ringing until the next mouseup. - if (typeof this._stopActiveBow === 'function') this._stopActiveBow(); - - let deviceId = rawValue; - let selectedChannel = undefined; - - if (rawValue.includes('::')) { - const parts = rawValue.split('::'); - deviceId = parts[0]; - selectedChannel = parseInt(parts[1]); - } - - this.selectedDevice = rawValue - ? this.devices.find(d => { - if (d._multiInstrument && selectedChannel !== undefined) { - return (d.device_id === deviceId || d.id === deviceId) && d.channel === selectedChannel; - } - return d.device_id === deviceId || d.id === deviceId; - }) || null - : null; + const nData = dropdown.querySelectorAll('.instrument-option:not(.option-none)').length; + if (nData === 0) { + dropdown.style.width = ''; + dropdown.style.maxHeight = ''; + dropdown.style.gridTemplateColumns = ''; + return; + } - this.stringInstrumentConfig = null; - - await this.loadDeviceCapabilities(deviceId || null, selectedChannel); - this.autoCenterKeyboard(); - this.updateSlidersVisibility(); - - this.modulation = 64; - this._updateModWheelPosition(64); - const modDisplay = document.getElementById('keyboard-modulation-display'); - if (modDisplay) modDisplay.textContent = '64'; - - this.updateLatencyDisplay(); - this._updateInstrumentTrigger(); - this._buildInstrumentDropdown(); // refresh selected state - - const info = this.getInstrumentViewInfo(); - const viewGroup = document.getElementById('keyboard-view-mode-group'); - // keyboard_type:'chromatic' instruments (xylophone, marimba, hangdrum …) use - // the equal-width list view as their default so fingers align with uniform slots. - const caps = this.selectedDeviceCapabilities; - const handsKbType = caps && caps.hands_config && caps.hands_config.keyboard_type; - const isChromatic = handsKbType === 'chromatic' || caps && caps.keyboard_type === 'chromatic'; - - // Always reset wind controls before re-evaluating the new instrument - if (typeof this._hideWindControls === 'function') this._hideWindControls(); - - if (info.isDrum) { - if (viewGroup) viewGroup.classList.remove('hidden'); - this.stringInstrumentConfig = null; - this._harpStringConfig = null; - this.setViewMode('drumpad'); - } else if (info.canFretboard) { - await this.loadStringInstrumentConfig(); - this._harpStringConfig = null; - if (viewGroup) viewGroup.classList.remove('hidden'); - this.setViewMode('fretboard'); - } else if (info.isWind) { - this.stringInstrumentConfig = null; - this._harpStringConfig = null; - // Keep the view-mode toggle visible so the user can switch a - // wind instrument to the standard piano (and back). - if (viewGroup) viewGroup.classList.remove('hidden'); - // Show wind articulation panel and switch to piano-slider (chromatic keys) - if (typeof this._showWindControls === 'function') { - this._showWindControls(info.windPreset); - } - this.setViewMode('piano-slider'); - // Comfort zone is applied inside generatePianoSlider via _applyWindComfortZone - } else if (isChromatic) { - this.stringInstrumentConfig = null; - this._harpStringConfig = null; - if (viewGroup) viewGroup.classList.add('hidden'); - this.setViewMode('keyboard-list'); - } else if (info.viewKind === 'harp') { - // Dedicated vertical-string HarpView. stringInstrumentConfig is - // kept null so the InstrumentDetector escape hatch doesn't flip - // a re-selected harp to the fretboard. HarpView derives its - // strings from the configured note selection via - // getInstrumentNoteRange(); _harpStringConfig is only the - // last-resort 47-string preset when no range is available. - this.stringInstrumentConfig = null; - this._harpStringConfig = this._harpPresetStringConfig(); - if (viewGroup) viewGroup.classList.remove('hidden'); - this.setViewMode('harp'); - } else if (['harmonica', 'accordion', 'mallet', 'music-box', - 'kalimba', 'bagpipe', 'steel-drum', 'perc-pad', - 'theremin'] - .includes(info.viewKind)) { - // Dedicated self-owned views. The view owns its DOM host; no - // built-in container or string config involved. The view-mode - // toggle group stays VISIBLE so the standard virtual piano is - // always reachable for any specific instrument (the toggle - // handler swaps this.viewMode ↔ 'piano' symmetrically). - this.stringInstrumentConfig = null; - this._harpStringConfig = null; - if (viewGroup) viewGroup.classList.remove('hidden'); - this.setViewMode(info.viewKind); - } else { - this.stringInstrumentConfig = null; - this._harpStringConfig = null; - if (viewGroup) viewGroup.classList.add('hidden'); - this.setViewMode('piano'); - } + const rect = trigger.getBoundingClientRect(); + const panelMode = !!dropdown.closest('.modal-dialog.km-panel-mode'); + + let availW = Math.min(window.innerWidth - 12, window.innerWidth - rect.left - 8); + availW = Math.max(320, availW); + const availH = Math.max(160, panelMode ? rect.top - 12 : window.innerHeight - rect.bottom - 12); + + const colsForW = (w) => Math.max(1, Math.floor((w - padX + gap) / (itemW + gap))); + // Compact baseline = the column count that fills the 320px min-width + // (≈ today's look); never go below it so the few-instrument case is + // unchanged. Cap = how many columns fit out to the screen edge. + const minCols = colsForW(320); + const maxCols = Math.max(minCols, colsForW(availW)); + + const heightFor = (cols) => { + const rows = Math.ceil(nData / cols); + return padY + noneH + rows * rowH + (rows - 1) * gap; + }; + + // Stay compact when everything already fits; otherwise widen just + // enough to fit the viewport height. If even the widest grid can't + // fit, use the widest one (scroll minimized "au max"). + let cols = maxCols; + for (let c = minCols; c <= maxCols; c++) { + if (heightFor(c) <= availH) { + cols = c; + break; + } + } - this.regeneratePianoKeys(); - - if (this._panelCallbacks?.onInstrumentSelected) { - // Pass the detected instrument type alongside the raw routing - // info so hosts (LoopEditor) don't have to re-derive isDrum / - // isWind from `gmProgram` + `channel` — those two alone miss - // drum kits whose device exposes a melodic gm_program on a - // non-9 channel. `info` comes from `getInstrumentViewInfo()` - // which already folds `caps.instrument_type === 'drum'` into - // the flag. `viewMode === 'drumpad'` is the authoritative - // tiebreaker — if we activated drum-pad UI just above, the - // user is clearly playing drums even if caps doesn't agree. - const finalIsDrum = info.isDrum === true || this.viewMode === 'drumpad'; - this._panelCallbacks.onInstrumentSelected({ - deviceId: this.selectedDevice?.device_id || this.selectedDevice?.id || null, - channel: this.getSelectedChannel(), - gmProgram: this.selectedDeviceCapabilities?.gm_program ?? 0, - instrumentType: info.instrumentType || this.selectedDeviceCapabilities?.instrument_type || null, - isDrum: finalIsDrum, - isWind: info.isWind === true, - viewMode: this.viewMode - }); - } + const width = Math.min(availW, Math.max(320, cols * itemW + (cols - 1) * gap + padX)); + + dropdown.style.gridTemplateColumns = `repeat(${cols}, 1fr)`; + dropdown.style.width = `${Math.round(width)}px`; + dropdown.style.maxHeight = `${Math.round(availH)}px`; + } + + _updateInstrumentTrigger() { + const triggerSvg = document.getElementById('instrument-trigger-svg'); + const triggerEmoji = document.getElementById('instrument-trigger-emoji'); + const triggerName = document.getElementById('instrument-trigger-name'); + if (!triggerName) return; + + if (!this.selectedDevice) { + if (triggerSvg) triggerSvg.style.display = 'none'; + if (triggerEmoji) { + triggerEmoji.textContent = '🎵'; + triggerEmoji.style.display = 'inline'; + } + triggerName.textContent = `— ${this.t('common.select')} —`; + return; } - /** - * Refresh the device list if the modal is open - */ - async refreshDevices() { - if (!this.isOpen) return; + const gmProgram = this.selectedDevice.gm_program; + const channel = this.selectedDevice.channel; + const icon = window.InstrumentFamilies + ? window.InstrumentFamilies.resolveInstrumentIcon({ gmProgram, channel }) + : { svgUrl: null, emoji: '🎵' }; + + if (icon.svgUrl && triggerSvg) { + triggerSvg.src = icon.svgUrl; + triggerSvg.style.display = 'block'; + triggerSvg.onerror = () => { + triggerSvg.style.display = 'none'; + if (triggerEmoji) { + triggerEmoji.textContent = icon.emoji; + triggerEmoji.style.display = 'inline'; + } + }; + if (triggerEmoji) triggerEmoji.style.display = 'none'; + } else { + if (triggerSvg) triggerSvg.style.display = 'none'; + if (triggerEmoji) { + triggerEmoji.textContent = icon.emoji; + triggerEmoji.style.display = 'inline'; + } + } - this.logger.info('[KeyboardModal] Refreshing devices...'); - await this.loadDevices(); - this.populateDeviceSelect(); + triggerName.textContent = this.selectedDevice.displayName || this.selectedDevice.name; + } + + async _selectInstrumentOption(rawValue) { + // Close dropdown + const dropdown = document.getElementById('instrument-dropdown'); + const selector = document.getElementById('header-instrument-selector'); + const trigger = document.getElementById('instrument-trigger'); + dropdown?.classList.remove('open'); + selector?.classList.remove('open'); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + this._sizeInstrumentDropdown(false); + + // Stop any held bow before swapping instruments — the new chord bar + // about to be rendered won't reuse the previous button reference, and + // the chord notes would otherwise keep ringing until the next mouseup. + if (typeof this._stopActiveBow === 'function') this._stopActiveBow(); + + let deviceId = rawValue; + let selectedChannel = undefined; + + if (rawValue.includes('::')) { + const parts = rawValue.split('::'); + deviceId = parts[0]; + selectedChannel = parseInt(parts[1]); } - _subscribeLocale() { - if (typeof i18n !== 'undefined') { - this.localeUnsubscribe = i18n.onLocaleChange(() => { - this.updateTranslations(); - this.populateDeviceSelect(); - }); - } + this.selectedDevice = rawValue + ? this.devices.find((d) => { + if (d._multiInstrument && selectedChannel !== undefined) { + return (d.device_id === deviceId || d.id === deviceId) && d.channel === selectedChannel; + } + return d.device_id === deviceId || d.id === deviceId; + }) || null + : null; + + this.stringInstrumentConfig = null; + + await this.loadDeviceCapabilities(deviceId || null, selectedChannel); + this.autoCenterKeyboard(); + this.updateSlidersVisibility(); + + this.modulation = 64; + this._updateModWheelPosition(64); + const modDisplay = document.getElementById('keyboard-modulation-display'); + if (modDisplay) modDisplay.textContent = '64'; + + this.updateLatencyDisplay(); + this._updateInstrumentTrigger(); + this._buildInstrumentDropdown(); // refresh selected state + + const info = this.getInstrumentViewInfo(); + const viewGroup = document.getElementById('keyboard-view-mode-group'); + // keyboard_type:'chromatic' instruments (xylophone, marimba, hangdrum …) use + // the equal-width list view as their default so fingers align with uniform slots. + const caps = this.selectedDeviceCapabilities; + const handsKbType = caps && caps.hands_config && caps.hands_config.keyboard_type; + const isChromatic = handsKbType === 'chromatic' || (caps && caps.keyboard_type === 'chromatic'); + + // Always reset wind controls before re-evaluating the new instrument + if (typeof this._hideWindControls === 'function') this._hideWindControls(); + + if (info.isDrum) { + if (viewGroup) viewGroup.classList.remove('hidden'); + this.stringInstrumentConfig = null; + this._harpStringConfig = null; + this.setViewMode('drumpad'); + } else if (info.canFretboard) { + await this.loadStringInstrumentConfig(); + this._harpStringConfig = null; + if (viewGroup) viewGroup.classList.remove('hidden'); + this.setViewMode('fretboard'); + } else if (info.isWind) { + this.stringInstrumentConfig = null; + this._harpStringConfig = null; + // Keep the view-mode toggle visible so the user can switch a + // wind instrument to the standard piano (and back). + if (viewGroup) viewGroup.classList.remove('hidden'); + // Show wind articulation panel and switch to piano-slider (chromatic keys) + if (typeof this._showWindControls === 'function') { + this._showWindControls(info.windPreset); + } + this.setViewMode('piano-slider'); + // Comfort zone is applied inside generatePianoSlider via _applyWindComfortZone + } else if (isChromatic) { + this.stringInstrumentConfig = null; + this._harpStringConfig = null; + if (viewGroup) viewGroup.classList.add('hidden'); + this.setViewMode('keyboard-list'); + } else if (info.viewKind === 'harp') { + // Dedicated vertical-string HarpView. stringInstrumentConfig is + // kept null so the InstrumentDetector escape hatch doesn't flip + // a re-selected harp to the fretboard. HarpView derives its + // strings from the configured note selection via + // getInstrumentNoteRange(); _harpStringConfig is only the + // last-resort 47-string preset when no range is available. + this.stringInstrumentConfig = null; + this._harpStringConfig = this._harpPresetStringConfig(); + if (viewGroup) viewGroup.classList.remove('hidden'); + this.setViewMode('harp'); + } else if ( + [ + 'harmonica', + 'accordion', + 'mallet', + 'music-box', + 'kalimba', + 'bagpipe', + 'steel-drum', + 'perc-pad', + 'theremin' + ].includes(info.viewKind) + ) { + // Dedicated self-owned views. The view owns its DOM host; no + // built-in container or string config involved. The view-mode + // toggle group stays VISIBLE so the standard virtual piano is + // always reachable for any specific instrument (the toggle + // handler swaps this.viewMode ↔ 'piano' symmetrically). + this.stringInstrumentConfig = null; + this._harpStringConfig = null; + if (viewGroup) viewGroup.classList.remove('hidden'); + this.setViewMode(info.viewKind); + } else { + this.stringInstrumentConfig = null; + this._harpStringConfig = null; + if (viewGroup) viewGroup.classList.add('hidden'); + this.setViewMode('piano'); } - mountAsPanel(panelContainer, callbacks = {}) { - if (this._panelMode) return; - if (this.isOpen) this.close(); + this.regeneratePianoKeys(); + + if (this._panelCallbacks?.onInstrumentSelected) { + // Pass the detected instrument type alongside the raw routing + // info so hosts (LoopEditor) don't have to re-derive isDrum / + // isWind from `gmProgram` + `channel` — those two alone miss + // drum kits whose device exposes a melodic gm_program on a + // non-9 channel. `info` comes from `getInstrumentViewInfo()` + // which already folds `caps.instrument_type === 'drum'` into + // the flag. `viewMode === 'drumpad'` is the authoritative + // tiebreaker — if we activated drum-pad UI just above, the + // user is clearly playing drums even if caps doesn't agree. + const finalIsDrum = info.isDrum === true || this.viewMode === 'drumpad'; + this._panelCallbacks.onInstrumentSelected({ + deviceId: this.selectedDevice?.device_id || this.selectedDevice?.id || null, + channel: this.getSelectedChannel(), + gmProgram: this.selectedDeviceCapabilities?.gm_program ?? 0, + instrumentType: + info.instrumentType || this.selectedDeviceCapabilities?.instrument_type || null, + isDrum: finalIsDrum, + isWind: info.isWind === true, + viewMode: this.viewMode + }); + } + } + + /** + * Refresh the device list if the modal is open + */ + async refreshDevices() { + if (!this.isOpen) return; + + this.logger.info('[KeyboardModal] Refreshing devices...'); + await this.loadDevices(); + this.populateDeviceSelect(); + } + + _subscribeLocale() { + if (typeof i18n !== 'undefined') { + this.localeUnsubscribe = i18n.onLocaleChange(() => { + this.updateTranslations(); + this.populateDeviceSelect(); + }); + } + } - this._panelCallbacks = callbacks; - this._panelMode = true; + mountAsPanel(panelContainer, callbacks = {}) { + if (this._panelMode) return; + if (this.isOpen) this.close(); - this.loadSettings(); - this.createModal(); - this.isOpen = true; - this.container.classList.add('km-panel-host'); // hide overlay shell — display:flex!important in CSS would override style.display + this._panelCallbacks = callbacks; + this._panelMode = true; - const dialogEl = this.container.querySelector('.modal-dialog'); - if (dialogEl) { - dialogEl.classList.add('km-panel-mode'); - panelContainer.appendChild(dialogEl); - this._panelDialogEl = dialogEl; - } + this.loadSettings(); + this.createModal(); + this.isOpen = true; + this.container.classList.add('km-panel-host'); // hide overlay shell — display:flex!important in CSS would override style.display - this.loadDevices().then(() => this.populateDeviceSelect()); - this.attachEvents(); - this.updateSlidersVisibility(); - this._subscribeLocale(); + const dialogEl = this.container.querySelector('.modal-dialog'); + if (dialogEl) { + dialogEl.classList.add('km-panel-mode'); + panelContainer.appendChild(dialogEl); + this._panelDialogEl = dialogEl; } - unmountPanel() { - if (!this._panelMode) return; + this.loadDevices().then(() => this.populateDeviceSelect()); + this.attachEvents(); + this.updateSlidersVisibility(); + this._subscribeLocale(); + } - if (this._panelDialogEl && this.container) { - this._panelDialogEl.classList.remove('km-panel-mode'); - this.container.classList.remove('km-panel-host'); - this.container.appendChild(this._panelDialogEl); - this._panelDialogEl = null; - } + unmountPanel() { + if (!this._panelMode) return; - this._panelMode = false; - this._panelCallbacks = null; - this.close(); // detachEvents, stop notes, removes this.container + if (this._panelDialogEl && this.container) { + this._panelDialogEl.classList.remove('km-panel-mode'); + this.container.classList.remove('km-panel-host'); + this.container.appendChild(this._panelDialogEl); + this._panelDialogEl = null; } + + this._panelMode = false; + this._panelCallbacks = null; + this.close(); // detachEvents, stop notes, removes this.container + } } // ============================================================================ @@ -1641,31 +1797,31 @@ class KeyboardModal { * @param {Object} mixin Object whose own properties become prototype methods. */ function _applyMixin(label, mixin) { - if (!mixin) return; - for (const key of Object.keys(mixin)) { - if (Object.prototype.hasOwnProperty.call(KeyboardModal.prototype, key)) { - // eslint-disable-next-line no-console - console.warn( - `[KeyboardModal] mixin "${label}" overrides existing method "${key}". ` + - `Expected only for KeyboardWind._updatePianoSliderGroupVisibility ` + - `(intentional, documented in KeyboardWind.js).` - ); - } - KeyboardModal.prototype[key] = mixin[key]; + if (!mixin) return; + for (const key of Object.keys(mixin)) { + if (Object.prototype.hasOwnProperty.call(KeyboardModal.prototype, key)) { + // eslint-disable-next-line no-console + console.warn( + `[KeyboardModal] mixin "${label}" overrides existing method "${key}". ` + + `Expected only for KeyboardWind._updatePianoSliderGroupVisibility ` + + `(intentional, documented in KeyboardWind.js).` + ); } + KeyboardModal.prototype[key] = mixin[key]; + } } -if (typeof KeyboardPianoMixin !== 'undefined') _applyMixin('Piano', KeyboardPianoMixin); -if (typeof KeyboardEventsMixin !== 'undefined') _applyMixin('Events', KeyboardEventsMixin); -if (typeof KeyboardControlsMixin !== 'undefined') _applyMixin('Controls', KeyboardControlsMixin); -if (typeof KeyboardChordsMixin !== 'undefined') _applyMixin('Chords', KeyboardChordsMixin); -if (typeof KeyboardSliderMixin !== 'undefined') _applyMixin('Slider', KeyboardSliderMixin); -if (typeof KeyboardListViewMixin !== 'undefined') _applyMixin('ListView', KeyboardListViewMixin); +if (typeof KeyboardPianoMixin !== 'undefined') _applyMixin('Piano', KeyboardPianoMixin); +if (typeof KeyboardEventsMixin !== 'undefined') _applyMixin('Events', KeyboardEventsMixin); +if (typeof KeyboardControlsMixin !== 'undefined') _applyMixin('Controls', KeyboardControlsMixin); +if (typeof KeyboardChordsMixin !== 'undefined') _applyMixin('Chords', KeyboardChordsMixin); +if (typeof KeyboardSliderMixin !== 'undefined') _applyMixin('Slider', KeyboardSliderMixin); +if (typeof KeyboardListViewMixin !== 'undefined') _applyMixin('ListView', KeyboardListViewMixin); // KM-C4 done: KeyboardWindMixin no longer overrides playNote. Wind // articulation + staccato now live in PianoSliderView via the // willPlayNote / afterPlayNote contract, so the `_windOrigPlayNote` // workaround is gone and the mixin applies like any other. -if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardWindMixin); +if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardWindMixin); // ─── Tracked DOM listeners (Phase E helper, KM-M3) ────────────────────────── // Subsequent code can call `this._on(el, 'click', fn)` instead of @@ -1673,21 +1829,25 @@ if (typeof KeyboardWindMixin !== 'undefined') _applyMixin('Wind', KeyboardW // Existing call sites are not migrated yet — this is opt-in for new code. KeyboardModal.prototype._on = function (el, evt, handler, opts) { - if (!el || !evt || typeof handler !== 'function') return; - el.addEventListener(evt, handler, opts); - (this._trackedListeners ||= []).push([el, evt, handler, opts]); + if (!el || !evt || typeof handler !== 'function') return; + el.addEventListener(evt, handler, opts); + (this._trackedListeners ||= []).push([el, evt, handler, opts]); }; KeyboardModal.prototype._offAll = function () { - if (!this._trackedListeners) return; - for (const [el, evt, h, o] of this._trackedListeners) { - try { el.removeEventListener(evt, h, o); } catch (_) { /* ignore */ } + if (!this._trackedListeners) return; + for (const [el, evt, h, o] of this._trackedListeners) { + try { + el.removeEventListener(evt, h, o); + } catch (_) { + /* ignore */ } - this._trackedListeners.length = 0; + } + this._trackedListeners.length = 0; }; // Expose the class on window so test runners + late-loading helpers can // inspect prototype. if (typeof window !== 'undefined') { - window.KeyboardModal = KeyboardModal; + window.KeyboardModal = KeyboardModal; } diff --git a/public/js/features/LightingControlPage.js b/public/js/features/LightingControlPage.js index 94cca6e58..bd1eb30ec 100644 --- a/public/js/features/LightingControlPage.js +++ b/public/js/features/LightingControlPage.js @@ -61,9 +61,20 @@ class LightingControlPage { `; document.body.appendChild(overlay); - overlay.querySelector('#_lcpConfirmYes').onclick = () => { overlay.remove(); resolve(true); }; - overlay.querySelector('#_lcpConfirmNo').onclick = () => { overlay.remove(); resolve(false); }; - overlay.addEventListener('click', (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } }); + overlay.querySelector('#_lcpConfirmYes').onclick = () => { + overlay.remove(); + resolve(true); + }; + overlay.querySelector('#_lcpConfirmNo').onclick = () => { + overlay.remove(); + resolve(false); + }; + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + overlay.remove(); + resolve(false); + } + }); }); } @@ -109,9 +120,15 @@ class LightingControlPage { } // Remove any open sub-panels - ['lightingDeviceForm', 'lightingRuleForm', 'lightingPresetsPanel', - 'lightingEffectsPanel', 'lightingGroupsPanel', 'lightingScanPanel', - 'lightingColorWheel'].forEach(id => { + [ + 'lightingDeviceForm', + 'lightingRuleForm', + 'lightingPresetsPanel', + 'lightingEffectsPanel', + 'lightingGroupsPanel', + 'lightingScanPanel', + 'lightingColorWheel' + ].forEach((id) => { document.getElementById(id)?.remove(); }); @@ -244,7 +261,9 @@ class LightingControlPage { this._setupEventDelegation(); // Close on overlay click - this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.close(); }); + this.modal.addEventListener('click', (e) => { + if (e.target === this.modal) this.close(); + }); // Responsive this._checkResponsive(); @@ -265,11 +284,20 @@ class LightingControlPage { // Simple actions (no params) const simpleMethods = [ - 'showEffectsPanel', 'showGroupsPanel', 'showPresetsPanel', - 'blackout', 'allOff', 'close', 'testDevice', - 'showAddDeviceForm', 'showEditDeviceForm', 'showAddRuleForm', - 'scanDevices', 'reconnectDevice', - '_testPreviewRainbow', '_clearPreview', + 'showEffectsPanel', + 'showGroupsPanel', + 'showPresetsPanel', + 'blackout', + 'allOff', + 'close', + 'testDevice', + 'showAddDeviceForm', + 'showEditDeviceForm', + 'showAddRuleForm', + 'scanDevices', + 'reconnectDevice', + '_testPreviewRainbow', + '_clearPreview', 'toggleLightingEnabled' ]; if (simpleMethods.includes(action) && typeof this[action] === 'function') { @@ -327,7 +355,12 @@ class LightingControlPage { if (w < 640) { if (tabs) tabs.style.display = 'flex'; if (this.mobilePanelView === 'devices') { - if (devicePanel) { devicePanel.style.display = 'flex'; devicePanel.style.width = '100%'; devicePanel.style.minWidth = '0'; devicePanel.style.borderRight = 'none'; } + if (devicePanel) { + devicePanel.style.display = 'flex'; + devicePanel.style.width = '100%'; + devicePanel.style.minWidth = '0'; + devicePanel.style.borderRight = 'none'; + } if (rulesPanel) rulesPanel.style.display = 'none'; } else { if (devicePanel) devicePanel.style.display = 'none'; @@ -335,7 +368,12 @@ class LightingControlPage { } } else { if (tabs) tabs.style.display = 'none'; - if (devicePanel) { devicePanel.style.display = 'flex'; devicePanel.style.width = ''; devicePanel.style.minWidth = ''; devicePanel.style.borderRight = ''; } + if (devicePanel) { + devicePanel.style.display = 'flex'; + devicePanel.style.width = ''; + devicePanel.style.minWidth = ''; + devicePanel.style.borderRight = ''; + } if (rulesPanel) rulesPanel.style.display = 'flex'; } } @@ -391,28 +429,40 @@ class LightingControlPage { try { const res = await this.apiClient.sendCommand('instrument_light_list'); states = (res && res.instruments) || []; - } catch (e) { /* no persisted states yet */ } + } catch (e) { + /* no persisted states yet */ + } const enabled = (this.instruments || []).filter( (i) => i.lighting_enabled === true || i.lighting_enabled === 1 ); if (enabled.length === 0) { host.innerHTML = `
- ${i18n.t('lighting.noLitInstruments') || 'Aucun instrument avec le contrôle lumière activé. Activez-le depuis les réglages d\'un instrument (onglet « Notes & Capacités »).'} + ${i18n.t('lighting.noLitInstruments') || "Aucun instrument avec le contrôle lumière activé. Activez-le depuis les réglages d'un instrument (onglet « Notes & Capacités »)."}
`; return; } const defaults = { - brightness: 0, effect: 0, hue: 0, speed: 64, intensity: 64, - supported_mask: 0, brightness_mode: 1, supported_effects: 0x3FF + brightness: 0, + effect: 0, + hue: 0, + speed: 64, + intensity: 64, + supported_mask: 0, + brightness_mode: 1, + supported_effects: 0x3ff }; - host.innerHTML = enabled.map((inst) => { - const name = inst.custom_name || inst.name || inst.device_id; - const ch = inst.channel || 0; - const stored = states.find((s) => s.device_id === inst.device_id && (s.channel || 0) === ch); - const st = { ...defaults, ...(stored || {}) }; - return this._renderLitPanel(inst.device_id, ch, name, st); - }).join(''); + host.innerHTML = enabled + .map((inst) => { + const name = inst.custom_name || inst.name || inst.device_id; + const ch = inst.channel || 0; + const stored = states.find( + (s) => s.device_id === inst.device_id && (s.channel || 0) === ch + ); + const st = { ...defaults, ...(stored || {}) }; + return this._renderLitPanel(inst.device_id, ch, name, st); + }) + .join(''); this._attachLitControlListeners(); } @@ -427,7 +477,7 @@ class LightingControlPage { `; if (mask === 0) { @@ -439,7 +489,7 @@ class LightingControlPage { ${cogBtn}

- ${esc(t('lighting.litNoCcDeclared', 'Aucun CC déclaré pour cet instrument. Activez les CC supportés dans les réglages de l\'instrument.'))} + ${esc(t('lighting.litNoCcDeclared', "Aucun CC déclaré pour cet instrument. Activez les CC supportés dans les réglages de l'instrument."))}

`; } @@ -447,14 +497,12 @@ class LightingControlPage { // Summary line: a discrete read-only list of every supported CC. const SUPPORTED = [ { bit: 0x01, cc: 110, label: t('lighting.lit.brightness', 'Lum.') }, - { bit: 0x02, cc: 111, label: t('lighting.lit.effect', 'Effet') }, - { bit: 0x04, cc: 112, label: t('lighting.lit.hue', 'Couleur') }, - { bit: 0x08, cc: 113, label: t('lighting.lit.speed', 'Vit.') }, - { bit: 0x10, cc: 114, label: t('lighting.lit.intensity', 'Int.') } + { bit: 0x02, cc: 111, label: t('lighting.lit.effect', 'Effet') }, + { bit: 0x04, cc: 112, label: t('lighting.lit.hue', 'Couleur') }, + { bit: 0x08, cc: 113, label: t('lighting.lit.speed', 'Vit.') }, + { bit: 0x10, cc: 114, label: t('lighting.lit.intensity', 'Int.') } ].filter((b) => (mask & b.bit) !== 0); - const summary = SUPPORTED - .map((b) => `CC${b.cc}`) - .join(' · '); + const summary = SUPPORTED.map((b) => `CC${b.cc}`).join(' · '); const rows = SUPPORTED.map((b) => this._renderLitRow(b, st, ds)).join(''); @@ -488,12 +536,14 @@ class LightingControlPage { const ICONS = { brightness: '💡', effect: '🎬', hue: '🎨', speed: '⏩', intensity: '✨' }; const FULL_LABEL = { brightness: t('lighting.litFull.brightness', 'Luminosité'), - effect: t('lighting.litFull.effect', 'Effet'), - hue: t('lighting.litFull.hue', 'Couleur'), - speed: t('lighting.litFull.speed', 'Vitesse'), - intensity: t('lighting.litFull.intensity', 'Intensité') + effect: t('lighting.litFull.effect', 'Effet'), + hue: t('lighting.litFull.hue', 'Couleur'), + speed: t('lighting.litFull.speed', 'Vitesse'), + intensity: t('lighting.litFull.intensity', 'Intensité') }; - const field = b.field || ({ 110: 'brightness', 111: 'effect', 112: 'hue', 113: 'speed', 114: 'intensity' }[b.cc]); + const field = + b.field || + { 110: 'brightness', 111: 'effect', 112: 'hue', 113: 'speed', 114: 'intensity' }[b.cc]; const labelHtml = `
@@ -517,26 +567,39 @@ class LightingControlPage { style="flex:1;padding:6px 12px;border-radius:16px;cursor:pointer;font-weight:600; border:1px solid ${on ? '#10b981' : 'var(--lt-border,#d1d5db)'}; background:${on ? 'rgba(16,185,129,0.15)' : 'transparent'};color:inherit;"> - ${on ? (i18n.t('lighting.litOnOffOn') || 'ON') : (i18n.t('lighting.litOnOffOff') || 'OFF')} + ${on ? i18n.t('lighting.litOnOffOn') || 'ON' : i18n.t('lighting.litOnOffOff') || 'OFF'} `; } if (b.field === 'effect') { const effects = [ - 'static', 'fade', 'pulse', 'blink', 'rainbow', - 'reactive_note', 'reactive_velocity', 'sparkle', 'fire', 'scanner' + 'static', + 'fade', + 'pulse', + 'blink', + 'rainbow', + 'reactive_note', + 'reactive_velocity', + 'sparkle', + 'fire', + 'scanner' ]; - const fxMask = st.supported_effects === undefined || st.supported_effects === null - ? 0x3FF : (st.supported_effects | 0); + const fxMask = + st.supported_effects === undefined || st.supported_effects === null + ? 0x3ff + : st.supported_effects | 0; const opts = effects .map((key, i) => ({ key, i })) .filter(({ i }) => (fxMask & (1 << i)) !== 0) .map(({ key, i }) => { const label = i18n.t('lighting.lightEffect.' + key) || key; return ``; - }).join(''); + }) + .join(''); const empty = opts.length === 0; return ``; } if (b.field === 'hue') { @@ -615,14 +678,18 @@ class LightingControlPage { el.classList.toggle('is-on', !isOn); el.setAttribute('aria-pressed', String(!isOn)); el.textContent = !isOn - ? (i18n.t('lighting.litOnOffOn') || 'ON') - : (i18n.t('lighting.litOnOffOff') || 'OFF'); + ? i18n.t('lighting.litOnOffOn') || 'ON' + : i18n.t('lighting.litOnOffOff') || 'OFF'; el.style.background = !isOn ? 'rgba(16,185,129,0.15)' : 'transparent'; el.style.borderColor = !isOn ? '#10b981' : 'var(--lt-border,#d1d5db)'; setState(el, { brightness: next }); - })); - host.querySelectorAll('.lit-effect').forEach((el) => - el.addEventListener('change', () => setState(el, { effect: parseInt(el.value, 10) }))); + }) + ); + host + .querySelectorAll('.lit-effect') + .forEach((el) => + el.addEventListener('change', () => setState(el, { effect: parseInt(el.value, 10) })) + ); rangeChange('.lit-hue', 'hue', (el) => { const swatch = rowOf(el)?.querySelector('.lit-hue-swatch'); if (swatch) swatch.style.background = LightingControlPage._hueToCss(parseInt(el.value, 10)); @@ -634,27 +701,43 @@ class LightingControlPage { /** HSV hue 0-127 → CSS color string. Static so it can run in tests. */ static _hueToCss(hue127) { const h = ((Math.max(0, Math.min(127, hue127 | 0)) / 128) * 6) % 6; - const c = 1, x = c * (1 - Math.abs((h % 2) - 1)); - let r = 0, g = 0, b = 0; - if (h < 1) { r = c; g = x; } - else if (h < 2) { r = x; g = c; } - else if (h < 3) { g = c; b = x; } - else if (h < 4) { g = x; b = c; } - else if (h < 5) { r = x; b = c; } - else { r = c; b = x; } + const c = 1, + x = c * (1 - Math.abs((h % 2) - 1)); + let r = 0, + g = 0, + b = 0; + if (h < 1) { + r = c; + g = x; + } else if (h < 2) { + r = x; + g = c; + } else if (h < 3) { + g = c; + b = x; + } else if (h < 4) { + g = x; + b = c; + } else if (h < 5) { + r = x; + b = c; + } else { + r = c; + b = x; + } const to = (v) => Math.round(v * 255); return `rgb(${to(r)}, ${to(g)}, ${to(b)})`; } _openInstrumentSettings(deviceId, channel) { const inst = (this.instruments || []).find( - i => i.device_id === deviceId && (i.channel || 0) === channel + (i) => i.device_id === deviceId && (i.channel || 0) === channel ); const device = { id: deviceId, channel: channel, - name: inst ? (inst.name || deviceId) : deviceId, - displayName: inst ? (inst.custom_name || inst.name || deviceId) : deviceId + name: inst ? inst.name || deviceId : deviceId, + displayName: inst ? inst.custom_name || inst.name || deviceId : deviceId }; // Close the lighting modal first to avoid stacked modals; the // per-instrument config is the single source of truth and lives there. @@ -704,13 +787,13 @@ class LightingControlPage { this._updateToggleBtn(); this.showToast( this.lightingEnabled - ? (i18n.t('lighting.lightsOnToast') || 'Lumières allumées') - : (i18n.t('lighting.lightsOffToast') || 'Lumières éteintes'), + ? i18n.t('lighting.lightsOnToast') || 'Lumières allumées' + : i18n.t('lighting.lightsOffToast') || 'Lumières éteintes', this.lightingEnabled ? 'success' : 'warning' ); } catch (error) { console.error('Failed to toggle lighting system:', error); - this.showToast(i18n.t('lighting.toggleError') || 'Erreur lors du changement d\'état', 'error'); + this.showToast(i18n.t('lighting.toggleError') || "Erreur lors du changement d'état", 'error'); } } @@ -742,25 +825,46 @@ class LightingControlPage { async _createGroup() { const name = document.getElementById('lgFormName')?.value.trim(); - if (!name) { this.showToast(i18n.t('lighting.nameRequired') || 'Nom requis', 'warning'); return; } + if (!name) { + this.showToast(i18n.t('lighting.nameRequired') || 'Nom requis', 'warning'); + return; + } const checkboxes = document.querySelectorAll('#lightingGroupsPanel .lgDeviceCb:checked'); - const deviceIds = [...checkboxes].map(cb => parseInt(cb.value)); - if (deviceIds.length === 0) { this.showToast(i18n.t('lighting.selectAtLeastOneDevice') || 'Sélectionnez au moins un dispositif', 'warning'); return; } + const deviceIds = [...checkboxes].map((cb) => parseInt(cb.value)); + if (deviceIds.length === 0) { + this.showToast( + i18n.t('lighting.selectAtLeastOneDevice') || 'Sélectionnez au moins un dispositif', + 'warning' + ); + return; + } try { await this.apiClient.sendCommand('lighting_group_create', { name, device_ids: deviceIds }); this.showGroupsPanel(); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async _deleteGroupByIdx(idx) { const name = this._groupNames?.[idx]; if (!name) return; - if (!await this._confirm((i18n.t('lighting.confirmDeleteGroup') || 'Supprimer le groupe « {name} » ?').replace('{name}', name))) return; + if ( + !(await this._confirm( + (i18n.t('lighting.confirmDeleteGroup') || 'Supprimer le groupe « {name} » ?').replace( + '{name}', + name + ) + )) + ) + return; try { await this.apiClient.sendCommand('lighting_group_delete', { name }); this.showGroupsPanel(); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async _setGroupColorByIdx(idx) { @@ -770,7 +874,9 @@ class LightingControlPage { const color = colorInput?.value || '#FF0000'; try { await this.apiClient.sendCommand('lighting_group_color', { name, color, brightness: 255 }); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async _groupOffByIdx(idx) { @@ -778,13 +884,15 @@ class LightingControlPage { if (!name) return; try { await this.apiClient.sendCommand('lighting_group_off', { name }); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } // ==================== DEVICE CLONE ==================== async cloneDevice(deviceId) { - const device = this.devices.find(d => d.id === deviceId); + const device = this.devices.find((d) => d.id === deviceId); if (!device) return; try { @@ -796,7 +904,9 @@ class LightingControlPage { enabled: false // Start disabled to avoid conflicts }); await this.loadData(); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async _startLiveEffect() { @@ -810,7 +920,9 @@ class LightingControlPage { await this.apiClient.sendCommand('lighting_effect_start', { device_id: this.selectedDeviceId, effect_type: effectType, - color, speed, brightness + color, + speed, + brightness }); // Refresh the panel this.showEffectsPanel(); @@ -826,7 +938,9 @@ class LightingControlPage { const inputEl = document.getElementById('leEffectBpmInput'); if (bpmEl) bpmEl.textContent = res.bpm; if (inputEl) inputEl.value = res.bpm; - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } } async _setBpm(value) { @@ -834,7 +948,9 @@ class LightingControlPage { const res = await this.apiClient.sendCommand('lighting_bpm_set', { bpm: parseInt(value) }); const bpmEl = document.getElementById('leEffectBpm'); if (bpmEl) bpmEl.textContent = res.bpm; - } catch (e) { /* ignore */ } + } catch (e) { + /* ignore */ + } } async _stopLiveEffect(effectKey) { @@ -859,12 +975,17 @@ class LightingControlPage { a.download = `lighting-rules-${new Date().toISOString().slice(0, 10)}.json`; a.click(); URL.revokeObjectURL(url); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async savePreset() { const name = document.getElementById('lpFormName')?.value.trim(); - if (!name) { this.showToast(i18n.t('lighting.presetName') || 'Nom requis', 'warning'); return; } + if (!name) { + this.showToast(i18n.t('lighting.presetName') || 'Nom requis', 'warning'); + return; + } try { await this.apiClient.sendCommand('lighting_preset_save', { name }); document.getElementById('lightingPresetsPanel')?.remove(); @@ -877,7 +998,13 @@ class LightingControlPage { } async loadPreset(id) { - if (!await this._confirm(i18n.t('lighting.confirmLoadPreset') || 'Charger ce preset ? Les règles actuelles seront remplacées.')) return; + if ( + !(await this._confirm( + i18n.t('lighting.confirmLoadPreset') || + 'Charger ce preset ? Les règles actuelles seront remplacées.' + )) + ) + return; try { await this.apiClient.sendCommand('lighting_preset_load', { id }); document.getElementById('lightingPresetsPanel')?.remove(); @@ -888,7 +1015,8 @@ class LightingControlPage { } async deletePreset(id) { - if (!await this._confirm(i18n.t('lighting.confirmDeletePreset') || 'Supprimer ce preset ?')) return; + if (!(await this._confirm(i18n.t('lighting.confirmDeletePreset') || 'Supprimer ce preset ?'))) + return; try { await this.apiClient.sendCommand('lighting_preset_delete', { id }); document.getElementById('lightingPresetsPanel')?.remove(); @@ -902,10 +1030,16 @@ class LightingControlPage { async saveScene() { const name = document.getElementById('lpSceneName')?.value.trim(); - if (!name) { this.showToast(i18n.t('lighting.sceneName') || 'Nom requis', 'warning'); return; } + if (!name) { + this.showToast(i18n.t('lighting.sceneName') || 'Nom requis', 'warning'); + return; + } try { await this.apiClient.sendCommand('lighting_scene_save', { name }); - this.showToast((i18n.t('lighting.sceneSaved') || 'Scène « {name} » sauvegardée').replace('{name}', name), 'success'); + this.showToast( + (i18n.t('lighting.sceneSaved') || 'Scène « {name} » sauvegardée').replace('{name}', name), + 'success' + ); document.getElementById('lightingPresetsPanel')?.remove(); const res = await this.apiClient.sendCommand('lighting_preset_list'); this.presets = res.presets || []; @@ -925,11 +1059,17 @@ class LightingControlPage { this._dmxProfiles = res.profiles || []; } - select.innerHTML = `` + - this._dmxProfiles.map(p => - `` - ).join(''); - } catch (e) { /* ignore - profiles not available */ } + select.innerHTML = + `` + + this._dmxProfiles + .map( + (p) => + `` + ) + .join(''); + } catch (e) { + /* ignore - profiles not available */ + } } _onDmxProfileChange(deviceType) { @@ -939,7 +1079,7 @@ class LightingControlPage { const channelsInput = document.getElementById(channelsId); if (!select || !channelsInput || !this._dmxProfiles) return; - const profile = this._dmxProfiles.find(p => p.key === select.value); + const profile = this._dmxProfiles.find((p) => p.key === select.value); if (profile) { channelsInput.value = profile.channels; } @@ -950,7 +1090,9 @@ class LightingControlPage { const gpioSelect = selectEl.closest('.strip-entry').querySelector('.strip-gpio'); const gpioMap = { 0: [18, 12], 1: [13, 19], 2: [10] }; const pins = gpioMap[ch] || []; - gpioSelect.innerHTML = pins.map((p, i) => ``).join(''); + gpioSelect.innerHTML = pins + .map((p, i) => ``) + .join(''); } _addSegmentEntry() { @@ -970,10 +1112,18 @@ class LightingControlPage { } async deleteDevice(id) { - if (!await this._confirm(i18n.t('lighting.confirmDeleteDevice') || 'Supprimer ce dispositif et toutes ses règles ?')) return; + if ( + !(await this._confirm( + i18n.t('lighting.confirmDeleteDevice') || 'Supprimer ce dispositif et toutes ses règles ?' + )) + ) + return; try { await this.apiClient.sendCommand('lighting_device_delete', { id }); - if (this.selectedDeviceId === id) { this.selectedDeviceId = null; this.rules = []; } + if (this.selectedDeviceId === id) { + this.selectedDeviceId = null; + this.rules = []; + } await this.loadData(); } catch (error) { this.showToast(error.message, 'error'); @@ -993,19 +1143,28 @@ class LightingControlPage { async reconnectDevice() { if (!this.selectedDeviceId) return; const btn = document.getElementById('lightingReconnectBtn'); - if (btn) { btn.textContent = `⏳ ${i18n.t('lighting.reconnecting') || 'Reconnexion...'}`; btn.disabled = true; } + if (btn) { + btn.textContent = `⏳ ${i18n.t('lighting.reconnecting') || 'Reconnexion...'}`; + btn.disabled = true; + } try { - await this.apiClient.sendCommand('lighting_device_update', { id: this.selectedDeviceId, enabled: true }); + await this.apiClient.sendCommand('lighting_device_update', { + id: this.selectedDeviceId, + enabled: true + }); await this.loadData(); } catch (error) { this.showToast(error.message, 'error'); } finally { - if (btn) { btn.textContent = `🔄 ${i18n.t('lighting.reconnect') || 'Reconnecter'}`; btn.disabled = false; } + if (btn) { + btn.textContent = `🔄 ${i18n.t('lighting.reconnect') || 'Reconnecter'}`; + btn.disabled = false; + } } } _populateSegmentDropdown(selectedSegment) { - const device = this.devices.find(d => d.id === this.selectedDeviceId); + const device = this.devices.find((d) => d.id === this.selectedDeviceId); const segRow = document.getElementById('lrFormSegmentRow'); const segSelect = document.getElementById('lrFormSegment'); if (!segRow || !segSelect || !device) return; @@ -1013,8 +1172,14 @@ class LightingControlPage { if (device.type === 'gpio_strip' && device.connection_config?.segments?.length) { segRow.style.display = 'block'; const segments = device.connection_config.segments; - segSelect.innerHTML = `` + - segments.map(s => ``).join(''); + segSelect.innerHTML = + `` + + segments + .map( + (s) => + `` + ) + .join(''); if (selectedSegment) this._onSegmentSelect(); } else { segRow.style.display = 'none'; @@ -1022,11 +1187,11 @@ class LightingControlPage { } _onSegmentSelect() { - const device = this.devices.find(d => d.id === this.selectedDeviceId); + const device = this.devices.find((d) => d.id === this.selectedDeviceId); const segName = document.getElementById('lrFormSegment')?.value; if (!segName || !device?.connection_config?.segments) return; - const seg = device.connection_config.segments.find(s => s.name === segName); + const seg = device.connection_config.segments.find((s) => s.name === segName); if (seg) { const startEl = document.getElementById('lrFormLedStart'); const endEl = document.getElementById('lrFormLedEnd'); @@ -1047,7 +1212,13 @@ class LightingControlPage { const nl = document.getElementById('lrFormNoteLedSection'); // Color picker: show for most types, hide for special modes - const hideColor = ['velocity_mapped', 'note_color', 'color_temp', 'random_color', 'note_led'].includes(type); + const hideColor = [ + 'velocity_mapped', + 'note_color', + 'color_temp', + 'random_color', + 'note_led' + ].includes(type); if (s) s.style.display = hideColor ? 'none' : 'block'; if (g) g.style.display = type === 'velocity_mapped' ? 'block' : 'none'; if (e) e.style.display = isEffect ? 'block' : 'none'; @@ -1069,12 +1240,12 @@ class LightingControlPage { // _clamp is provided by LightingHelpersMixin async editRule(ruleId) { - const rule = this.rules.find(r => r.id === ruleId); + const rule = this.rules.find((r) => r.id === ruleId); if (rule) this.showAddRuleForm(rule); } async cloneRule(ruleId) { - const rule = this.rules.find(r => r.id === ruleId); + const rule = this.rules.find((r) => r.id === ruleId); if (!rule) return; try { await this.apiClient.sendCommand('lighting_rule_add', { @@ -1087,42 +1258,55 @@ class LightingControlPage { action_config: rule.action_config }); await this.loadRulesForDevice(this.selectedDeviceId); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async deleteRule(id) { - if (!await this._confirm(i18n.t('lighting.confirmDeleteRule') || 'Supprimer cette règle ?')) return; + if (!(await this._confirm(i18n.t('lighting.confirmDeleteRule') || 'Supprimer cette règle ?'))) + return; try { await this.apiClient.sendCommand('lighting_rule_delete', { id }); await this.loadRulesForDevice(this.selectedDeviceId); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async toggleRule(id, enabled) { try { await this.apiClient.sendCommand('lighting_rule_update', { id, enabled }); await this.loadRulesForDevice(this.selectedDeviceId); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async batchToggleRules(enabled) { try { const updates = this.rules - .filter(rule => rule.enabled !== enabled) - .map(rule => this.apiClient.sendCommand('lighting_rule_update', { id: rule.id, enabled })); + .filter((rule) => rule.enabled !== enabled) + .map((rule) => + this.apiClient.sendCommand('lighting_rule_update', { id: rule.id, enabled }) + ); await Promise.all(updates); await this.loadRulesForDevice(this.selectedDeviceId); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } async moveRulePriority(id, delta) { - const rule = this.rules.find(r => r.id === id); + const rule = this.rules.find((r) => r.id === id); if (!rule) return; const newPriority = (rule.priority || 0) + delta; try { await this.apiClient.sendCommand('lighting_rule_update', { id, priority: newPriority }); await this.loadRulesForDevice(this.selectedDeviceId); - } catch (error) { this.showToast(error.message, 'error'); } + } catch (error) { + this.showToast(error.message, 'error'); + } } // Actions (testDevice, testRule, allOff, blackout, _onMasterDimmerChange) @@ -1137,16 +1321,16 @@ class LightingControlPage { // this file. Methods defined directly on the class take precedence over mixin // methods (they are applied first, class methods shadow them). // ============================================================================ -(function() { +(function () { const mixins = [ window.LightingHelpersMixin, window.LightingFormsMixin, window.LightingDeviceUIMixin, window.LightingPresetsUIMixin ]; - mixins.forEach(mixin => { + mixins.forEach((mixin) => { if (!mixin) return; - Object.keys(mixin).forEach(key => { + Object.keys(mixin).forEach((key) => { // Only add mixin method if NOT already defined on the class prototype // This avoids overwriting class methods with mixin duplicates if (!Object.hasOwn(LightingControlPage.prototype, key)) { diff --git a/public/js/features/LoopCreatorMinimap.js b/public/js/features/LoopCreatorMinimap.js index 7b28ea99b..b04674b5a 100644 --- a/public/js/features/LoopCreatorMinimap.js +++ b/public/js/features/LoopCreatorMinimap.js @@ -22,250 +22,292 @@ * m.draw(); // force immediate redraw * m.destroy(); */ -(function() { - 'use strict'; +(function () { + 'use strict'; - class LoopCreatorMinimap { - constructor(canvas, opts = {}) { - this.canvas = canvas; - this.ppq = Number.isFinite(opts.ppq) && opts.ppq > 0 ? opts.ppq : 480; - this.timeSigNum = Number.isFinite(opts.timeSigNum) && opts.timeSigNum > 0 ? opts.timeSigNum : 4; - this.bars = Number.isFinite(opts.bars) && opts.bars > 0 ? opts.bars : 2; - this.noteMin = Number.isFinite(opts.noteMin) ? opts.noteMin : 36; - this.noteMax = Number.isFinite(opts.noteMax) ? opts.noteMax : 84; - this.onSeek = typeof opts.onSeek === 'function' ? opts.onSeek : null; + class LoopCreatorMinimap { + constructor(canvas, opts = {}) { + this.canvas = canvas; + this.ppq = Number.isFinite(opts.ppq) && opts.ppq > 0 ? opts.ppq : 480; + this.timeSigNum = + Number.isFinite(opts.timeSigNum) && opts.timeSigNum > 0 ? opts.timeSigNum : 4; + this.bars = Number.isFinite(opts.bars) && opts.bars > 0 ? opts.bars : 2; + this.noteMin = Number.isFinite(opts.noteMin) ? opts.noteMin : 36; + this.noteMax = Number.isFinite(opts.noteMax) ? opts.noteMax : 84; + this.onSeek = typeof opts.onSeek === 'function' ? opts.onSeek : null; - this._notes = []; - this._xoffset = 0; - this._xrange = 0; - this._cursor = 0; - this._isPlaying = false; - this._dragging = false; - this._dirty = false; - this._rafHandle = null; + this._notes = []; + this._xoffset = 0; + this._xrange = 0; + this._cursor = 0; + this._isPlaying = false; + this._dragging = false; + this._dirty = false; + this._rafHandle = null; - if (canvas?.addEventListener) { - this._onMouseDown = (e) => { this._dragging = true; this._seek(e.clientX); }; - this._onMouseMove = (e) => { if (this._dragging) this._seek(e.clientX); }; - this._onMouseUp = () => { this._dragging = false; }; - this._onMouseLeave = () => { this._dragging = false; }; - this._onTouchStart = (e) => { e.preventDefault(); this._dragging = true; this._seek(e.touches[0].clientX); }; - this._onTouchMove = (e) => { e.preventDefault(); if (this._dragging) this._seek(e.touches[0].clientX); }; - this._onTouchEnd = () => { this._dragging = false; }; - this._onKeyDown = (e) => this._handleKeyDown(e); + if (canvas?.addEventListener) { + this._onMouseDown = (e) => { + this._dragging = true; + this._seek(e.clientX); + }; + this._onMouseMove = (e) => { + if (this._dragging) this._seek(e.clientX); + }; + this._onMouseUp = () => { + this._dragging = false; + }; + this._onMouseLeave = () => { + this._dragging = false; + }; + this._onTouchStart = (e) => { + e.preventDefault(); + this._dragging = true; + this._seek(e.touches[0].clientX); + }; + this._onTouchMove = (e) => { + e.preventDefault(); + if (this._dragging) this._seek(e.touches[0].clientX); + }; + this._onTouchEnd = () => { + this._dragging = false; + }; + this._onKeyDown = (e) => this._handleKeyDown(e); - canvas.addEventListener('mousedown', this._onMouseDown); - canvas.addEventListener('mousemove', this._onMouseMove); - canvas.addEventListener('mouseup', this._onMouseUp); - canvas.addEventListener('mouseleave', this._onMouseLeave); - canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); - canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); - canvas.addEventListener('touchend', this._onTouchEnd); - canvas.addEventListener('keydown', this._onKeyDown); - } - } + canvas.addEventListener('mousedown', this._onMouseDown); + canvas.addEventListener('mousemove', this._onMouseMove); + canvas.addEventListener('mouseup', this._onMouseUp); + canvas.addEventListener('mouseleave', this._onMouseLeave); + canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); + canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); + canvas.addEventListener('touchend', this._onTouchEnd); + canvas.addEventListener('keydown', this._onKeyDown); + } + } - // ----------------------------------------------------------------- - // Public setters — each schedules a coalesced rAF draw - // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // Public setters — each schedules a coalesced rAF draw + // ----------------------------------------------------------------- - setNotes(seq) { - this._notes = Array.isArray(seq) ? seq : []; - this._scheduleDraw(); - } + setNotes(seq) { + this._notes = Array.isArray(seq) ? seq : []; + this._scheduleDraw(); + } - setViewport(xoffset, xrange) { - this._xoffset = Number.isFinite(xoffset) ? xoffset : 0; - this._xrange = Number.isFinite(xrange) ? xrange : 0; - this._scheduleDraw(); - } + setViewport(xoffset, xrange) { + this._xoffset = Number.isFinite(xoffset) ? xoffset : 0; + this._xrange = Number.isFinite(xrange) ? xrange : 0; + this._scheduleDraw(); + } - setPlayhead(cursor, isPlaying) { - this._cursor = Number.isFinite(cursor) ? cursor : 0; - this._isPlaying = !!isPlaying; - this._scheduleDraw(); - } + setPlayhead(cursor, isPlaying) { + this._cursor = Number.isFinite(cursor) ? cursor : 0; + this._isPlaying = !!isPlaying; + this._scheduleDraw(); + } - setConfig({ ppq, timeSigNum, bars, noteMin, noteMax } = {}) { - if (Number.isFinite(ppq) && ppq > 0) this.ppq = ppq; - if (Number.isFinite(timeSigNum) && timeSigNum > 0) this.timeSigNum = timeSigNum; - if (Number.isFinite(bars) && bars > 0) this.bars = bars; - if (Number.isFinite(noteMin)) this.noteMin = noteMin; - if (Number.isFinite(noteMax)) this.noteMax = noteMax; - this._scheduleDraw(); - } + setConfig({ ppq, timeSigNum, bars, noteMin, noteMax } = {}) { + if (Number.isFinite(ppq) && ppq > 0) this.ppq = ppq; + if (Number.isFinite(timeSigNum) && timeSigNum > 0) this.timeSigNum = timeSigNum; + if (Number.isFinite(bars) && bars > 0) this.bars = bars; + if (Number.isFinite(noteMin)) this.noteMin = noteMin; + if (Number.isFinite(noteMax)) this.noteMax = noteMax; + this._scheduleDraw(); + } - // ----------------------------------------------------------------- - // rAF coalescing - // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // rAF coalescing + // ----------------------------------------------------------------- - _scheduleDraw() { - this._dirty = true; - if (this._rafHandle != null) return; - this._rafHandle = window.requestAnimationFrame(() => { - this._rafHandle = null; - if (this._dirty) this.draw(); - }); - } + _scheduleDraw() { + this._dirty = true; + if (this._rafHandle != null) return; + this._rafHandle = window.requestAnimationFrame(() => { + this._rafHandle = null; + if (this._dirty) this.draw(); + }); + } - // ----------------------------------------------------------------- - // Seek interaction - // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // Seek interaction + // ----------------------------------------------------------------- - _seek(clientX) { - if (!this.canvas || !this.onSeek) return; - const rect = this.canvas.getBoundingClientRect(); - const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); - const total = this._totalTicks(); - const xrange = this._xrange || total; - const newOffset = Math.max(0, Math.min(total - xrange, - Math.round(ratio * total - xrange / 2))); - this.onSeek(newOffset); - } + _seek(clientX) { + if (!this.canvas || !this.onSeek) return; + const rect = this.canvas.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const total = this._totalTicks(); + const xrange = this._xrange || total; + const newOffset = Math.max( + 0, + Math.min(total - xrange, Math.round(ratio * total - xrange / 2)) + ); + this.onSeek(newOffset); + } - // Keyboard navigation: Left/Right ±beat, Shift+Left/Right ±bar, Home/End extremes. - _handleKeyDown(e) { - if (!this.onSeek) return; - const total = this._totalTicks(); - const xrange = this._xrange || total; - const maxOff = Math.max(0, total - xrange); - const beat = this.ppq; - const bar = beat * this.timeSigNum; - const step = e.shiftKey ? bar : beat; - let next = this._xoffset; - switch (e.key) { - case 'ArrowLeft': next = this._xoffset - step; break; - case 'ArrowRight': next = this._xoffset + step; break; - case 'Home': next = 0; break; - case 'End': next = maxOff; break; - default: return; - } - e.preventDefault(); - this.onSeek(Math.max(0, Math.min(maxOff, Math.round(next)))); - } + // Keyboard navigation: Left/Right ±beat, Shift+Left/Right ±bar, Home/End extremes. + _handleKeyDown(e) { + if (!this.onSeek) return; + const total = this._totalTicks(); + const xrange = this._xrange || total; + const maxOff = Math.max(0, total - xrange); + const beat = this.ppq; + const bar = beat * this.timeSigNum; + const step = e.shiftKey ? bar : beat; + let next = this._xoffset; + switch (e.key) { + case 'ArrowLeft': + next = this._xoffset - step; + break; + case 'ArrowRight': + next = this._xoffset + step; + break; + case 'Home': + next = 0; + break; + case 'End': + next = maxOff; + break; + default: + return; + } + e.preventDefault(); + this.onSeek(Math.max(0, Math.min(maxOff, Math.round(next)))); + } - _totalTicks() { return this.ppq * this.timeSigNum * this.bars; } + _totalTicks() { + return this.ppq * this.timeSigNum * this.bars; + } - // ----------------------------------------------------------------- - // Draw - // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // Draw + // ----------------------------------------------------------------- - draw() { - this._dirty = false; - const canvas = this.canvas; - if (!canvas) return; - const ctx = canvas.getContext('2d'); + draw() { + this._dirty = false; + const canvas = this.canvas; + if (!canvas) return; + const ctx = canvas.getContext('2d'); - const w = canvas.clientWidth || canvas.offsetWidth || 0; - const h = canvas.clientHeight || canvas.offsetHeight || 0; - if (w <= 0 || h <= 0) return; + const w = canvas.clientWidth || canvas.offsetWidth || 0; + const h = canvas.clientHeight || canvas.offsetHeight || 0; + if (w <= 0 || h <= 0) return; - // DPR-aware canvas sizing (fixes rendering on retina screens) - const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; - const wantW = Math.round(w * dpr); - const wantH = Math.round(h * dpr); - if (canvas.width !== wantW || canvas.height !== wantH) { - canvas.width = wantW; - canvas.height = wantH; - } - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + // DPR-aware canvas sizing (fixes rendering on retina screens) + const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1; + const wantW = Math.round(w * dpr); + const wantH = Math.round(h * dpr); + if (canvas.width !== wantW || canvas.height !== wantH) { + canvas.width = wantW; + canvas.height = wantH; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - const total = this._totalTicks(); - const dark = typeof document !== 'undefined' - && document.body.classList.contains('theme-dark'); + const total = this._totalTicks(); + const dark = + typeof document !== 'undefined' && document.body.classList.contains('theme-dark'); - ctx.fillStyle = dark ? '#0f0f22' : '#eef0f4'; - ctx.fillRect(0, 0, w, h); + ctx.fillStyle = dark ? '#0f0f22' : '#eef0f4'; + ctx.fillRect(0, 0, w, h); - this._drawGrid(ctx, w, h, total, dark); - this._drawNotes(ctx, w, h, total, dark); - this._drawViewport(ctx, w, h, total); - this._drawPlayhead(ctx, w, h, total); - } + this._drawGrid(ctx, w, h, total, dark); + this._drawNotes(ctx, w, h, total, dark); + this._drawViewport(ctx, w, h, total); + this._drawPlayhead(ctx, w, h, total); + } - _drawGrid(ctx, w, h, total, dark) { - const ticksPerBeat = this.ppq; - const ticksPerBar = ticksPerBeat * this.timeSigNum; - ctx.lineWidth = 1; - for (let t = 0; t < total; t += ticksPerBeat) { - const x = (t / total) * w; - const isBar = (t % ticksPerBar) === 0; - ctx.strokeStyle = isBar - ? (dark ? 'rgba(150,150,220,0.35)' : 'rgba(100,110,140,0.3)') - : (dark ? 'rgba(100,100,180,0.12)' : 'rgba(160,170,200,0.15)'); - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); - } - ctx.fillStyle = dark ? 'rgba(180,180,220,0.5)' : 'rgba(100,110,140,0.55)'; - ctx.font = `${Math.min(10, h * 0.27)}px sans-serif`; - ctx.textBaseline = 'top'; - for (let b = 0; b < this.bars; b++) { - const x = (b * ticksPerBar / total) * w; - ctx.fillText(b + 1, x + 2, 2); - } - } + _drawGrid(ctx, w, h, total, dark) { + const ticksPerBeat = this.ppq; + const ticksPerBar = ticksPerBeat * this.timeSigNum; + ctx.lineWidth = 1; + for (let t = 0; t < total; t += ticksPerBeat) { + const x = (t / total) * w; + const isBar = t % ticksPerBar === 0; + ctx.strokeStyle = isBar + ? dark + ? 'rgba(150,150,220,0.35)' + : 'rgba(100,110,140,0.3)' + : dark + ? 'rgba(100,100,180,0.12)' + : 'rgba(160,170,200,0.15)'; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + ctx.fillStyle = dark ? 'rgba(180,180,220,0.5)' : 'rgba(100,110,140,0.55)'; + ctx.font = `${Math.min(10, h * 0.27)}px sans-serif`; + ctx.textBaseline = 'top'; + for (let b = 0; b < this.bars; b++) { + const x = ((b * ticksPerBar) / total) * w; + ctx.fillText(b + 1, x + 2, 2); + } + } - _drawNotes(ctx, w, h, total, dark) { - const noteSpan = Math.max(this.noteMax - this.noteMin, 1); - const noteH = Math.max(2, h * 0.08); - const noteArea = h * 0.78; - ctx.fillStyle = dark ? '#5b9bd5' : '#4a90d9'; - for (const note of this._notes) { - const x = (note.t / total) * w; - const nw = Math.max(2, ((note.g || note.l || 120) / total) * w); - const ny = h * 0.18 + noteArea - ((note.n - this.noteMin) / noteSpan) * noteArea; - ctx.fillRect(x, ny - noteH, nw, noteH); - } - } + _drawNotes(ctx, w, h, total, dark) { + const noteSpan = Math.max(this.noteMax - this.noteMin, 1); + const noteH = Math.max(2, h * 0.08); + const noteArea = h * 0.78; + ctx.fillStyle = dark ? '#5b9bd5' : '#4a90d9'; + for (const note of this._notes) { + const x = (note.t / total) * w; + const nw = Math.max(2, ((note.g || note.l || 120) / total) * w); + const ny = h * 0.18 + noteArea - ((note.n - this.noteMin) / noteSpan) * noteArea; + ctx.fillRect(x, ny - noteH, nw, noteH); + } + } - _drawViewport(ctx, w, h, total) { - const xrange = this._xrange || total; - const vx = (this._xoffset / total) * w; - const vw = Math.min(w, (xrange / total) * w); - ctx.fillStyle = 'rgba(74,144,217,0.12)'; - ctx.fillRect(vx, 0, vw, h); - ctx.strokeStyle = 'rgba(74,144,217,0.7)'; - ctx.lineWidth = 1.5; - ctx.strokeRect(vx + 0.5, 0.5, Math.max(2, vw - 1), h - 1); - } + _drawViewport(ctx, w, h, total) { + const xrange = this._xrange || total; + const vx = (this._xoffset / total) * w; + const vw = Math.min(w, (xrange / total) * w); + ctx.fillStyle = 'rgba(74,144,217,0.12)'; + ctx.fillRect(vx, 0, vw, h); + ctx.strokeStyle = 'rgba(74,144,217,0.7)'; + ctx.lineWidth = 1.5; + ctx.strokeRect(vx + 0.5, 0.5, Math.max(2, vw - 1), h - 1); + } - _drawPlayhead(ctx, w, h, total) { - if (this._cursor <= 0 && !this._isPlaying) return; - const px = (this._cursor / total) * w; - ctx.strokeStyle = '#e74c3c'; - ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, h); ctx.stroke(); - } + _drawPlayhead(ctx, w, h, total) { + if (this._cursor <= 0 && !this._isPlaying) return; + const px = (this._cursor / total) * w; + ctx.strokeStyle = '#e74c3c'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, h); + ctx.stroke(); + } - // ----------------------------------------------------------------- - // Lifecycle - // ----------------------------------------------------------------- + // ----------------------------------------------------------------- + // Lifecycle + // ----------------------------------------------------------------- - destroy() { - if (this._rafHandle) { - cancelAnimationFrame(this._rafHandle); - this._rafHandle = null; - } - const c = this.canvas; - if (c?.removeEventListener) { - c.removeEventListener('mousedown', this._onMouseDown); - c.removeEventListener('mousemove', this._onMouseMove); - c.removeEventListener('mouseup', this._onMouseUp); - c.removeEventListener('mouseleave', this._onMouseLeave); - c.removeEventListener('touchstart', this._onTouchStart); - c.removeEventListener('touchmove', this._onTouchMove); - c.removeEventListener('touchend', this._onTouchEnd); - c.removeEventListener('keydown', this._onKeyDown); - } - this._notes = []; - this.canvas = null; - this.onSeek = null; - } + destroy() { + if (this._rafHandle) { + cancelAnimationFrame(this._rafHandle); + this._rafHandle = null; + } + const c = this.canvas; + if (c?.removeEventListener) { + c.removeEventListener('mousedown', this._onMouseDown); + c.removeEventListener('mousemove', this._onMouseMove); + c.removeEventListener('mouseup', this._onMouseUp); + c.removeEventListener('mouseleave', this._onMouseLeave); + c.removeEventListener('touchstart', this._onTouchStart); + c.removeEventListener('touchmove', this._onTouchMove); + c.removeEventListener('touchend', this._onTouchEnd); + c.removeEventListener('keydown', this._onKeyDown); + } + this._notes = []; + this.canvas = null; + this.onSeek = null; } + } - if (typeof window !== 'undefined') { - window.LoopCreatorMinimap = LoopCreatorMinimap; - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopCreatorMinimap; - } + if (typeof window !== 'undefined') { + window.LoopCreatorMinimap = LoopCreatorMinimap; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopCreatorMinimap; + } })(); diff --git a/public/js/features/LoopCreatorModal.js b/public/js/features/LoopCreatorModal.js index 0517daf47..97d54f2aa 100644 --- a/public/js/features/LoopCreatorModal.js +++ b/public/js/features/LoopCreatorModal.js @@ -13,475 +13,841 @@ // ── GM program data ─────────────────────────────────────────── const GM_PROGRAM_NAMES = [ - 'Acoustic Grand Piano','Bright Acoustic Piano','Electric Grand Piano','Honky-tonk Piano', - 'Electric Piano 1','Electric Piano 2','Harpsichord','Clavinet', - 'Celesta','Glockenspiel','Music Box','Vibraphone','Marimba','Xylophone','Tubular Bells','Dulcimer', - 'Drawbar Organ','Percussive Organ','Rock Organ','Church Organ','Reed Organ','Accordion','Harmonica','Tango Accordion', - 'Acoustic Guitar (nylon)','Acoustic Guitar (steel)','Electric Guitar (jazz)','Electric Guitar (clean)', - 'Electric Guitar (muted)','Overdriven Guitar','Distortion Guitar','Guitar Harmonics', - 'Acoustic Bass','Electric Bass (finger)','Electric Bass (pick)','Fretless Bass', - 'Slap Bass 1','Slap Bass 2','Synth Bass 1','Synth Bass 2', - 'Violin','Viola','Cello','Contrabass','Tremolo Strings','Pizzicato Strings','Orchestral Harp','Timpani', - 'String Ensemble 1','String Ensemble 2','Synth Strings 1','Synth Strings 2', - 'Choir Aahs','Voice Oohs','Synth Voice','Orchestra Hit', - 'Trumpet','Trombone','Tuba','Muted Trumpet','French Horn','Brass Section','Synth Brass 1','Synth Brass 2', - 'Soprano Sax','Alto Sax','Tenor Sax','Baritone Sax', - 'Oboe','English Horn','Bassoon','Clarinet', - 'Piccolo','Flute','Recorder','Pan Flute','Blown Bottle','Shakuhachi','Whistle','Ocarina', - 'Lead 1 (square)','Lead 2 (sawtooth)','Lead 3 (calliope)','Lead 4 (chiff)', - 'Lead 5 (charang)','Lead 6 (voice)','Lead 7 (fifths)','Lead 8 (bass + lead)', - 'Pad 1 (new age)','Pad 2 (warm)','Pad 3 (polysynth)','Pad 4 (choir)', - 'Pad 5 (bowed)','Pad 6 (metallic)','Pad 7 (halo)','Pad 8 (sweep)', - 'FX 1 (rain)','FX 2 (soundtrack)','FX 3 (crystal)','FX 4 (atmosphere)', - 'FX 5 (brightness)','FX 6 (goblins)','FX 7 (echoes)','FX 8 (sci-fi)', - 'Sitar','Banjo','Shamisen','Koto','Kalimba','Bagpipe','Fiddle','Shanai', - 'Tinkle Bell','Agogo','Steel Drums','Woodblock','Taiko Drum','Melodic Tom','Synth Drum','Reverse Cymbal', - 'Guitar Fret Noise','Breath Noise','Seashore','Bird Tweet','Telephone Ring','Helicopter','Applause','Gunshot' + 'Acoustic Grand Piano', + 'Bright Acoustic Piano', + 'Electric Grand Piano', + 'Honky-tonk Piano', + 'Electric Piano 1', + 'Electric Piano 2', + 'Harpsichord', + 'Clavinet', + 'Celesta', + 'Glockenspiel', + 'Music Box', + 'Vibraphone', + 'Marimba', + 'Xylophone', + 'Tubular Bells', + 'Dulcimer', + 'Drawbar Organ', + 'Percussive Organ', + 'Rock Organ', + 'Church Organ', + 'Reed Organ', + 'Accordion', + 'Harmonica', + 'Tango Accordion', + 'Acoustic Guitar (nylon)', + 'Acoustic Guitar (steel)', + 'Electric Guitar (jazz)', + 'Electric Guitar (clean)', + 'Electric Guitar (muted)', + 'Overdriven Guitar', + 'Distortion Guitar', + 'Guitar Harmonics', + 'Acoustic Bass', + 'Electric Bass (finger)', + 'Electric Bass (pick)', + 'Fretless Bass', + 'Slap Bass 1', + 'Slap Bass 2', + 'Synth Bass 1', + 'Synth Bass 2', + 'Violin', + 'Viola', + 'Cello', + 'Contrabass', + 'Tremolo Strings', + 'Pizzicato Strings', + 'Orchestral Harp', + 'Timpani', + 'String Ensemble 1', + 'String Ensemble 2', + 'Synth Strings 1', + 'Synth Strings 2', + 'Choir Aahs', + 'Voice Oohs', + 'Synth Voice', + 'Orchestra Hit', + 'Trumpet', + 'Trombone', + 'Tuba', + 'Muted Trumpet', + 'French Horn', + 'Brass Section', + 'Synth Brass 1', + 'Synth Brass 2', + 'Soprano Sax', + 'Alto Sax', + 'Tenor Sax', + 'Baritone Sax', + 'Oboe', + 'English Horn', + 'Bassoon', + 'Clarinet', + 'Piccolo', + 'Flute', + 'Recorder', + 'Pan Flute', + 'Blown Bottle', + 'Shakuhachi', + 'Whistle', + 'Ocarina', + 'Lead 1 (square)', + 'Lead 2 (sawtooth)', + 'Lead 3 (calliope)', + 'Lead 4 (chiff)', + 'Lead 5 (charang)', + 'Lead 6 (voice)', + 'Lead 7 (fifths)', + 'Lead 8 (bass + lead)', + 'Pad 1 (new age)', + 'Pad 2 (warm)', + 'Pad 3 (polysynth)', + 'Pad 4 (choir)', + 'Pad 5 (bowed)', + 'Pad 6 (metallic)', + 'Pad 7 (halo)', + 'Pad 8 (sweep)', + 'FX 1 (rain)', + 'FX 2 (soundtrack)', + 'FX 3 (crystal)', + 'FX 4 (atmosphere)', + 'FX 5 (brightness)', + 'FX 6 (goblins)', + 'FX 7 (echoes)', + 'FX 8 (sci-fi)', + 'Sitar', + 'Banjo', + 'Shamisen', + 'Koto', + 'Kalimba', + 'Bagpipe', + 'Fiddle', + 'Shanai', + 'Tinkle Bell', + 'Agogo', + 'Steel Drums', + 'Woodblock', + 'Taiko Drum', + 'Melodic Tom', + 'Synth Drum', + 'Reverse Cymbal', + 'Guitar Fret Noise', + 'Breath Noise', + 'Seashore', + 'Bird Tweet', + 'Telephone Ring', + 'Helicopter', + 'Applause', + 'Gunshot' ]; -const GM_FAMILIES = (typeof window !== 'undefined' && window.LoopUtils && window.LoopUtils.GM_FAMILIES) || []; +const GM_FAMILIES = + (typeof window !== 'undefined' && window.LoopUtils && window.LoopUtils.GM_FAMILIES) || []; // ARRANGER_HISTORY_LIMIT moved to LoopManagerArrangerFeature (audit §6.6). class LoopManagerModal extends BaseModal { - /** Back-compat: feature owns the loop array; modal exposes it readonly. */ - get library() { return this.libraryFeature?.library || []; } - /** Back-compat: Library cards read this Map to display play indicator. */ - get _livePlayingLoops() { return this.liveFeature?.playingLoops || new Map(); } - /** Back-compat: a few call-sites read modal._liveSynth directly. */ - get _liveSynth() { return this.liveFeature?.synth || null; } - /** Back-compat: Library cards / _deleteLoopById read this array. */ - get _padSlots() { return this.padFeature?.slots || []; } - /** Back-compat: _switchTab + _onClick read this flag. */ - get _padPickerIndex() { return this.padFeature?.pickerIndex ?? null; } - /** Back-compat: _renderPlaybar iterates Pad start/dur per index. */ - get _padPlayTimes() { return this.padFeature?.playTimes || new Map(); } - /** Back-compat: doClose calls this if armed. */ - get _padClearLongPress() { return this.padFeature ? () => this.padFeature.clearLongPress() : null; } - /** Back-compat: _renderPlaybar inspects which pads are currently playing. */ - get _padPlayingIndex() { return this.padFeature?.playingIndex || new Set(); } - /** Back-compat: every feature reads `modal._globalOutput`. */ - get _globalOutput() { return this.outputRouter?.globalOutput || { mode: 'synth', deviceId: null, channel: 0 }; } - /** Back-compat: Live/Pad stop-all reads `_deviceShim` for cancelAllNotes. */ - get _deviceShim() { return this.outputRouter?.deviceShim || null; } - set _deviceShim(v) { if (this.outputRouter) this.outputRouter.deviceShim = v; } - /** Back-compat: cached device list. */ - get _cachedDevices() { return this.outputRouter?.cachedDevices || []; } - - constructor(api, eventBus) { - super({ - id: 'loop-manager-modal', - size: 'full', - title: 'loopManager.title', - closeOnOverlay: false, - customClass: 'loop-creator-modal' - }); - this.api = api; - this.eventBus = eventBus || window.eventBus || null; - - this.activeTab = 'library'; - - // ── Library state ── - // ── Library tab feature (extracted to LoopManagerLibraryFeature - // per audit §6.6). Owns `library` array + search/filter/sort state. - // The modal exposes `this.library` as a getter for back-compat with - // the other features that read it directly (Pad, Live, Arranger). - this.libraryFeature = typeof LoopManagerLibraryFeature !== 'undefined' - ? new LoopManagerLibraryFeature(this, { - onDeleteLoop: (id) => this._deleteLoopById(id), - onOpenLoopEditor: (id) => this._loopEditor.open({ loopId: id }), - onLibraryLoaded: () => { - if (this.activeTab === 'live') this._renderLiveArea(); - this._renderPalette(); - } - }) - : null; - - // ── Arranger state ── - this.currentArrangementId = null; - this.arrangementName = ''; - this.arrangementTempo = 120; - this.arrangementBars = 16; - this.tracks = []; - this.blocks = []; - this._arrangerZoom = 1; // horizontal zoom factor for the timeline - this.isArrangerPlaying = false; - this._arrangerTimers = []; - this._arrangerSynth = null; - this._dragInfo = null; - this._dropPreview = null; - - // Arranger undo/redo (local to current arrangement) - this._arrHistory = []; - this._arrHistoryIdx = -1; - this._arrDirty = false; // explicit init (pre-refactor relied on undefined→falsy) - this._selectedBlocks = new Set(); - - // Arranger UX state - this._trackMute = new Set(); // track ids muted (local, not persisted) - this._trackSolo = new Set(); // track ids soloed - this._blockClipboard = []; // copied blocks (relative positions) - this._resizeState = null; // active block resize state - this._autoSaveTimer = null; // debounced metadata save - this._blockMenuEl = null; // open context menu element - this._paletteSearch = ''; // palette filter text - this._arrangerZoomV = 1; // vertical zoom (track height multiplier) - this._arrangerLoop = false; // loop arrangement playback - this._arrangerCountIn = false; // play 1-bar metronome before start - this._arrangerStartBar = 0; // bar offset where playback begins - this._trackDragId = null; // track currently being reordered (visual only) - - // Shared loop data cache (pad + live + arranger) - this._fetchLoopDataCache = new Map(); - - // ── Pad tab feature (extracted to LoopManagerPadFeature per audit §6.6). - // Owns the grid layout, slots, play mode, quantize, synth, picker. - this.padFeature = typeof LoopManagerPadFeature !== 'undefined' - ? new LoopManagerPadFeature(this) - : null; - - // ── Arranger tab feature (extracted to LoopManagerArrangerFeature - // per audit §6.6). State stays on the modal (currentArrangementId, - // tracks, blocks, _arr*, etc.) — the feature holds the methods. - this.arrangerFeature = typeof LoopManagerArrangerFeature !== 'undefined' - ? new LoopManagerArrangerFeature(this) - : null; - - // ── View (HTML rendering) and Events sub-features (audit §1.3). - this.view = typeof LoopCreatorModalView !== 'undefined' - ? new LoopCreatorModalView(this) - : null; - this.events = typeof LoopCreatorModalEvents !== 'undefined' - ? new LoopCreatorModalEvents(this) - : null; - - // ── Live state ── - // ── Live tab feature (extracted to LoopManagerLiveFeature - // per audit §6.6). Owns playingLoops Map, synth, search. - this.liveFeature = typeof LoopManagerLiveFeature !== 'undefined' - ? new LoopManagerLiveFeature(this) - : null; - - // ── Keyboard tab feature (extracted to LoopManagerKeyboardFeature - // per audit §6.6). Owns its own state (synth, mounted, envelopes, - // activeKeys, instrument, isDrum). Public API used by this modal: - // keyboard.enterTab(), keyboard.unmount(), keyboard.stopAllNotes(), - // keyboard.mounted. - this.keyboard = typeof LoopManagerKeyboardFeature !== 'undefined' - ? new LoopManagerKeyboardFeature(this) - : null; - - // ── Playback timeline bar ── - // (_padPlayTimes lives on padFeature.playTimes; getter below.) - this._arrangerStartTime = 0; - this._playbarRAF = null; - - // Arranger output (synth by default) - this.outputMode = 'synth'; - this.outputDeviceId = null; - this.outputChannel = 0; - - // ── Global output selector (header) — drives Pad / Live / Arranger ── - // Extracted to LoopManagerOutputRouter (audit §6.6). State lives - // on the router; the modal exposes getters for back-compat. - this.outputRouter = typeof LoopManagerOutputRouter !== 'undefined' - ? new LoopManagerOutputRouter(this) - : null; - - // Bound doc handlers for arranger drag - this._boundDocMouseUp = this._onDocMouseUp.bind(this); - this._boundDocMouseMove = this._onDocMouseMove.bind(this); - this._boundKeyDown = this._onKeyDown.bind(this); - - // Loop editor — created once, shared across sessions - this._loopEditor = new LoopEditorModal(api, eventBus, { - onSaved: (loopId) => this._onLoopSaved(loopId) - }); - } - - // ========================================================= - // RENDERING — SHELL - // ========================================================= - - // Delegates to view sub-feature (extracted per audit §1.3) - _renderHeader() { return this.view?._renderHeader() ?? ''; } - renderBody() { return this.view?.renderBody() ?? ''; } - _renderKeyboardTab() { return this.view?._renderKeyboardTab() ?? ''; } - renderFooter() { return this.view?.renderFooter() ?? ''; } - _renderLibraryTab() { return this.view?._renderLibraryTab() ?? ''; } - _renderPadTab() { return this.view?._renderPadTab() ?? ''; } - _renderLiveTab() { return this.view?._renderLiveTab() ?? ''; } - _renderArrangerTab() { return this.view?._renderArrangerTab() ?? ''; } - - // ========================================================= - // LIFECYCLE - // ========================================================= - - onOpen() { - // _createDOM has already rendered the body with constructor defaults. - // Load persisted layout, then push the loaded values back onto the bar - // (cols/rows inputs + mode/quantize buttons) so the UI matches state. - this._loadPadLayout(); - this._syncPadControls(); - this._initArrangerSynth(); - this._initPadSynth(); - this._initLiveSynth(); - this._attachEvents(); - this._loadLibrary(); - this._loadHeaderOutputDevices(); - document.addEventListener('mouseup', this._boundDocMouseUp); - document.addEventListener('mousemove', this._boundDocMouseMove); - document.addEventListener('keydown', this._boundKeyDown); - - // Run the per-tab init that `_switchTab` would normally trigger. - // Without this, opening on a non-library tab (or even library if - // the async library load hasn't fired yet) leaves the pane - // visually selected but unpopulated until the user manually - // clicks another tab and back. - this._initActiveTab(); - } - - /** - * Initial-render hook : run the same per-tab init that `_switchTab` - * performs, but for the currently active tab. Idempotent — every - * `_render*` / `_init*` it calls is safe to invoke twice. - */ - _initActiveTab() { - switch (this.activeTab) { - case 'library': this._filterAndRenderLibrary(); break; - case 'pad': this._renderPadGrid(); break; - case 'live': this._renderLiveArea(); break; - case 'keyboard': this.keyboard?.enterTab(); break; - case 'arranger': this._initArrangerTab(); break; - } - } - - // ── Global output selector (header) ──────────────────────── - async _loadHeaderOutputDevices() { return this.outputRouter?.loadDevices(); } - _refreshHeaderOutputUI() { this.outputRouter?.refreshUI(); } - _toggleHeaderOutput() { this.outputRouter?.toggleMode(); } - _setGlobalOutput(next) { this.outputRouter?.setOutput(next); } - _panicCurrentDevice(target) { this.outputRouter?.panicTarget(target); } - _getOutputTarget(fallbackSynth) { return this.outputRouter?.getTarget(fallbackSynth) ?? fallbackSynth; } - - - close() { - if (!this._arrDirty) { - super.close(); - return; - } - // Confirmation accessible (AUDIT §A1). - const doSuperClose = () => super.close(); - LoopUtils.confirm(this.t('loopManager.confirmDiscardChanges'), { - icon: '⚠️', - danger: true - }).then((ok) => { if (ok) doSuperClose(); }); - } - - onClose() { - if (this._autoSaveTimer) { clearTimeout(this._autoSaveTimer); this._autoSaveTimer = null; } - this._closeBlockMenu(); - this._stopAllPads(); - this._liveStopAll(); - this._stopArrangerPlay(); - this._stopPlaybarRAF(); - this._closePadPicker(); - this.keyboard?.stopAllNotes(); - if (this.keyboard?.mounted) this.keyboard.unmount(); - // Coupe les timers/UI résiduels du Pad et de l'Arranger (AUDIT §L9, §L10). - if (typeof this._padClearLongPress === 'function') this._padClearLongPress(); - this._hideDropPreview(); - document.removeEventListener('mouseup', this._boundDocMouseUp); - document.removeEventListener('mousemove', this._boundDocMouseMove); - document.removeEventListener('keydown', this._boundKeyDown); - } - - // ========================================================= - // KEYBOARD SHORTCUTS - // ========================================================= - - // Delegates to events sub-feature (extracted per audit §1.3) - _onKeyDown(e) { return this.events?._onKeyDown(e); } - _switchTab(tab) { return this.events?._switchTab(tab); } - _attachEvents() { return this.events?._attachEvents(); } - _onClick(e) { return this.events?._onClick(e); } - _onContextMenu(e) { return this.events?._onContextMenu(e); } - _openBlockMenu(x, y) { return this.events?._openBlockMenu(x, y); } - _closeBlockMenu() { return this.events?._closeBlockMenu(); } - _onChange(e) { return this.events?._onChange(e); } - _onInput(e) { return this.events?._onInput(e); } - - // ========================================================= - // LIBRARY - // ========================================================= - - async _loadLibrary() { - return this.libraryFeature?.loadLibrary(); + /** Back-compat: feature owns the loop array; modal exposes it readonly. */ + get library() { + return this.libraryFeature?.library || []; + } + /** Back-compat: Library cards read this Map to display play indicator. */ + get _livePlayingLoops() { + return this.liveFeature?.playingLoops || new Map(); + } + /** Back-compat: a few call-sites read modal._liveSynth directly. */ + get _liveSynth() { + return this.liveFeature?.synth || null; + } + /** Back-compat: Library cards / _deleteLoopById read this array. */ + get _padSlots() { + return this.padFeature?.slots || []; + } + /** Back-compat: _switchTab + _onClick read this flag. */ + get _padPickerIndex() { + return this.padFeature?.pickerIndex ?? null; + } + /** Back-compat: _renderPlaybar iterates Pad start/dur per index. */ + get _padPlayTimes() { + return this.padFeature?.playTimes || new Map(); + } + /** Back-compat: doClose calls this if armed. */ + get _padClearLongPress() { + return this.padFeature ? () => this.padFeature.clearLongPress() : null; + } + /** Back-compat: _renderPlaybar inspects which pads are currently playing. */ + get _padPlayingIndex() { + return this.padFeature?.playingIndex || new Set(); + } + /** Back-compat: every feature reads `modal._globalOutput`. */ + get _globalOutput() { + return this.outputRouter?.globalOutput || { mode: 'synth', deviceId: null, channel: 0 }; + } + /** Back-compat: Live/Pad stop-all reads `_deviceShim` for cancelAllNotes. */ + get _deviceShim() { + return this.outputRouter?.deviceShim || null; + } + set _deviceShim(v) { + if (this.outputRouter) this.outputRouter.deviceShim = v; + } + /** Back-compat: cached device list. */ + get _cachedDevices() { + return this.outputRouter?.cachedDevices || []; + } + + constructor(api, eventBus) { + super({ + id: 'loop-manager-modal', + size: 'full', + title: 'loopManager.title', + closeOnOverlay: false, + customClass: 'loop-creator-modal' + }); + this.api = api; + this.eventBus = eventBus || window.eventBus || null; + + this.activeTab = 'library'; + + // ── Library state ── + // ── Library tab feature (extracted to LoopManagerLibraryFeature + // per audit §6.6). Owns `library` array + search/filter/sort state. + // The modal exposes `this.library` as a getter for back-compat with + // the other features that read it directly (Pad, Live, Arranger). + this.libraryFeature = + typeof LoopManagerLibraryFeature !== 'undefined' + ? new LoopManagerLibraryFeature(this, { + onDeleteLoop: (id) => this._deleteLoopById(id), + onOpenLoopEditor: (id) => this._loopEditor.open({ loopId: id }), + onLibraryLoaded: () => { + if (this.activeTab === 'live') this._renderLiveArea(); + this._renderPalette(); + } + }) + : null; + + // ── Arranger state ── + this.currentArrangementId = null; + this.arrangementName = ''; + this.arrangementTempo = 120; + this.arrangementBars = 16; + this.tracks = []; + this.blocks = []; + this._arrangerZoom = 1; // horizontal zoom factor for the timeline + this.isArrangerPlaying = false; + this._arrangerTimers = []; + this._arrangerSynth = null; + this._dragInfo = null; + this._dropPreview = null; + + // Arranger undo/redo (local to current arrangement) + this._arrHistory = []; + this._arrHistoryIdx = -1; + this._arrDirty = false; // explicit init (pre-refactor relied on undefined→falsy) + this._selectedBlocks = new Set(); + + // Arranger UX state + this._trackMute = new Set(); // track ids muted (local, not persisted) + this._trackSolo = new Set(); // track ids soloed + this._blockClipboard = []; // copied blocks (relative positions) + this._resizeState = null; // active block resize state + this._autoSaveTimer = null; // debounced metadata save + this._blockMenuEl = null; // open context menu element + this._paletteSearch = ''; // palette filter text + this._arrangerZoomV = 1; // vertical zoom (track height multiplier) + this._arrangerLoop = false; // loop arrangement playback + this._arrangerCountIn = false; // play 1-bar metronome before start + this._arrangerStartBar = 0; // bar offset where playback begins + this._trackDragId = null; // track currently being reordered (visual only) + + // Shared loop data cache (pad + live + arranger) + this._fetchLoopDataCache = new Map(); + + // ── Pad tab feature (extracted to LoopManagerPadFeature per audit §6.6). + // Owns the grid layout, slots, play mode, quantize, synth, picker. + this.padFeature = + typeof LoopManagerPadFeature !== 'undefined' ? new LoopManagerPadFeature(this) : null; + + // ── Arranger tab feature (extracted to LoopManagerArrangerFeature + // per audit §6.6). State stays on the modal (currentArrangementId, + // tracks, blocks, _arr*, etc.) — the feature holds the methods. + this.arrangerFeature = + typeof LoopManagerArrangerFeature !== 'undefined' + ? new LoopManagerArrangerFeature(this) + : null; + + // ── View (HTML rendering) and Events sub-features (audit §1.3). + this.view = typeof LoopCreatorModalView !== 'undefined' ? new LoopCreatorModalView(this) : null; + this.events = + typeof LoopCreatorModalEvents !== 'undefined' ? new LoopCreatorModalEvents(this) : null; + + // ── Live state ── + // ── Live tab feature (extracted to LoopManagerLiveFeature + // per audit §6.6). Owns playingLoops Map, synth, search. + this.liveFeature = + typeof LoopManagerLiveFeature !== 'undefined' ? new LoopManagerLiveFeature(this) : null; + + // ── Keyboard tab feature (extracted to LoopManagerKeyboardFeature + // per audit §6.6). Owns its own state (synth, mounted, envelopes, + // activeKeys, instrument, isDrum). Public API used by this modal: + // keyboard.enterTab(), keyboard.unmount(), keyboard.stopAllNotes(), + // keyboard.mounted. + this.keyboard = + typeof LoopManagerKeyboardFeature !== 'undefined' + ? new LoopManagerKeyboardFeature(this) + : null; + + // ── Playback timeline bar ── + // (_padPlayTimes lives on padFeature.playTimes; getter below.) + this._arrangerStartTime = 0; + this._playbarRAF = null; + + // Arranger output (synth by default) + this.outputMode = 'synth'; + this.outputDeviceId = null; + this.outputChannel = 0; + + // ── Global output selector (header) — drives Pad / Live / Arranger ── + // Extracted to LoopManagerOutputRouter (audit §6.6). State lives + // on the router; the modal exposes getters for back-compat. + this.outputRouter = + typeof LoopManagerOutputRouter !== 'undefined' ? new LoopManagerOutputRouter(this) : null; + + // Bound doc handlers for arranger drag + this._boundDocMouseUp = this._onDocMouseUp.bind(this); + this._boundDocMouseMove = this._onDocMouseMove.bind(this); + this._boundKeyDown = this._onKeyDown.bind(this); + + // Loop editor — created once, shared across sessions + this._loopEditor = new LoopEditorModal(api, eventBus, { + onSaved: (loopId) => this._onLoopSaved(loopId) + }); + } + + // ========================================================= + // RENDERING — SHELL + // ========================================================= + + // Delegates to view sub-feature (extracted per audit §1.3) + _renderHeader() { + return this.view?._renderHeader() ?? ''; + } + renderBody() { + return this.view?.renderBody() ?? ''; + } + _renderKeyboardTab() { + return this.view?._renderKeyboardTab() ?? ''; + } + renderFooter() { + return this.view?.renderFooter() ?? ''; + } + _renderLibraryTab() { + return this.view?._renderLibraryTab() ?? ''; + } + _renderPadTab() { + return this.view?._renderPadTab() ?? ''; + } + _renderLiveTab() { + return this.view?._renderLiveTab() ?? ''; + } + _renderArrangerTab() { + return this.view?._renderArrangerTab() ?? ''; + } + + // ========================================================= + // LIFECYCLE + // ========================================================= + + onOpen() { + // _createDOM has already rendered the body with constructor defaults. + // Load persisted layout, then push the loaded values back onto the bar + // (cols/rows inputs + mode/quantize buttons) so the UI matches state. + this._loadPadLayout(); + this._syncPadControls(); + this._initArrangerSynth(); + this._initPadSynth(); + this._initLiveSynth(); + this._attachEvents(); + this._loadLibrary(); + this._loadHeaderOutputDevices(); + document.addEventListener('mouseup', this._boundDocMouseUp); + document.addEventListener('mousemove', this._boundDocMouseMove); + document.addEventListener('keydown', this._boundKeyDown); + + // Run the per-tab init that `_switchTab` would normally trigger. + // Without this, opening on a non-library tab (or even library if + // the async library load hasn't fired yet) leaves the pane + // visually selected but unpopulated until the user manually + // clicks another tab and back. + this._initActiveTab(); + } + + /** + * Initial-render hook : run the same per-tab init that `_switchTab` + * performs, but for the currently active tab. Idempotent — every + * `_render*` / `_init*` it calls is safe to invoke twice. + */ + _initActiveTab() { + switch (this.activeTab) { + case 'library': + this._filterAndRenderLibrary(); + break; + case 'pad': + this._renderPadGrid(); + break; + case 'live': + this._renderLiveArea(); + break; + case 'keyboard': + this.keyboard?.enterTab(); + break; + case 'arranger': + this._initArrangerTab(); + break; } - - _filterAndRenderLibrary() { - this.libraryFeature?.filterAndRender(); + } + + // ── Global output selector (header) ──────────────────────── + async _loadHeaderOutputDevices() { + return this.outputRouter?.loadDevices(); + } + _refreshHeaderOutputUI() { + this.outputRouter?.refreshUI(); + } + _toggleHeaderOutput() { + this.outputRouter?.toggleMode(); + } + _setGlobalOutput(next) { + this.outputRouter?.setOutput(next); + } + _panicCurrentDevice(target) { + this.outputRouter?.panicTarget(target); + } + _getOutputTarget(fallbackSynth) { + return this.outputRouter?.getTarget(fallbackSynth) ?? fallbackSynth; + } + + close() { + if (!this._arrDirty) { + super.close(); + return; } - - - async _deleteLoopById(id) { - try { - await this.api.sendCommand('loop_delete', { loopId: id }); - this._fetchLoopDataCache.delete(id); - // Remove from pad slots if assigned (cross-feature cleanup) - this.padFeature?.cleanupSlotsForLoop(id); - // Remove from live playing - this._liveStop(id); - await this._loadLibrary(); - if (this.currentArrangementId && this.blocks.some(b => b.loop_id === id)) { - await this._loadArrangementById(this.currentArrangementId); - } - } catch (err) { - LoopUtils.handleError(err, 'manager.deleteLoop', { - toast: this.t('loopManager.errDeleteLoop') - }); - } + // Confirmation accessible (AUDIT §A1). + const doSuperClose = () => super.close(); + LoopUtils.confirm(this.t('loopManager.confirmDiscardChanges'), { + icon: '⚠️', + danger: true + }).then((ok) => { + if (ok) doSuperClose(); + }); + } + + onClose() { + if (this._autoSaveTimer) { + clearTimeout(this._autoSaveTimer); + this._autoSaveTimer = null; } - - _onLoopSaved(loopId) { - this._fetchLoopDataCache.delete(loopId); - this._loadLibrary(); + this._closeBlockMenu(); + this._stopAllPads(); + this._liveStopAll(); + this._stopArrangerPlay(); + this._stopPlaybarRAF(); + this._closePadPicker(); + this.keyboard?.stopAllNotes(); + if (this.keyboard?.mounted) this.keyboard.unmount(); + // Coupe les timers/UI résiduels du Pad et de l'Arranger (AUDIT §L9, §L10). + if (typeof this._padClearLongPress === 'function') this._padClearLongPress(); + this._hideDropPreview(); + document.removeEventListener('mouseup', this._boundDocMouseUp); + document.removeEventListener('mousemove', this._boundDocMouseMove); + document.removeEventListener('keydown', this._boundKeyDown); + } + + // ========================================================= + // KEYBOARD SHORTCUTS + // ========================================================= + + // Delegates to events sub-feature (extracted per audit §1.3) + _onKeyDown(e) { + return this.events?._onKeyDown(e); + } + _switchTab(tab) { + return this.events?._switchTab(tab); + } + _attachEvents() { + return this.events?._attachEvents(); + } + _onClick(e) { + return this.events?._onClick(e); + } + _onContextMenu(e) { + return this.events?._onContextMenu(e); + } + _openBlockMenu(x, y) { + return this.events?._openBlockMenu(x, y); + } + _closeBlockMenu() { + return this.events?._closeBlockMenu(); + } + _onChange(e) { + return this.events?._onChange(e); + } + _onInput(e) { + return this.events?._onInput(e); + } + + // ========================================================= + // LIBRARY + // ========================================================= + + async _loadLibrary() { + return this.libraryFeature?.loadLibrary(); + } + + _filterAndRenderLibrary() { + this.libraryFeature?.filterAndRender(); + } + + async _deleteLoopById(id) { + try { + await this.api.sendCommand('loop_delete', { loopId: id }); + this._fetchLoopDataCache.delete(id); + // Remove from pad slots if assigned (cross-feature cleanup) + this.padFeature?.cleanupSlotsForLoop(id); + // Remove from live playing + this._liveStop(id); + await this._loadLibrary(); + if (this.currentArrangementId && this.blocks.some((b) => b.loop_id === id)) { + await this._loadArrangementById(this.currentArrangementId); + } + } catch (err) { + LoopUtils.handleError(err, 'manager.deleteLoop', { + toast: this.t('loopManager.errDeleteLoop') + }); } - - _gmProgramName(prog) { return this.view?.gmProgramName(prog) ?? `Program ${prog}`; } - _instrIconHtml(prog, kind, extraClass) { return this.view?.instrIconHtml(prog, kind, extraClass) ?? ""; } - - // ========================================================= - // PAD TAB - // ========================================================= - - async _initPadSynth() { return this.padFeature?.initSynth(); } - _renderPadGrid() { this.padFeature?.renderGrid(); } - _syncPadControls() { this.padFeature?._syncControls(); } - _setPadCols(v) { this.padFeature?.setCols(v); } - _setPadRows(v) { this.padFeature?.setRows(v); } - _adjustPadCols(d) { this.padFeature?.adjustCols(d); } - _adjustPadRows(d) { this.padFeature?.adjustRows(d); } - _setPadPlayMode(mode) { this.padFeature?.setPlayMode(mode); } - _setPadQuantize(q) { this.padFeature?.setQuantize(q); } - async _triggerPad(i, opts){ return this.padFeature?.trigger(i, opts); } - _stopPad(i) { this.padFeature?.stop(i); } - _stopAllPads() { this.padFeature?.stopAll(); } - _assignPadSlot(i, loopId) { this.padFeature?.assignSlot(i, loopId); } - _openPadPicker(i, anchor) { this.padFeature?.openPicker(i, anchor); } - _closePadPicker() { this.padFeature?.closePicker(); } - _persistPadLayout() { this.padFeature?._persist(); } - _loadPadLayout() { this.padFeature?.load(); } - async _clearAllPads() { return this.padFeature?.clearAll(); } - - - // ========================================================= - // LIVE TAB - // ========================================================= - - async _initLiveSynth() { return this.liveFeature?.initSynth(); } - _renderLiveArea() { this.liveFeature?.renderArea(); } - async _liveTrigger(id) { return this.liveFeature?.trigger(id); } - _liveStop(id) { this.liveFeature?.stop(id); } - _liveStopAll() { this.liveFeature?.stopAll(); } - // ========================================================= - // ARRANGER — delegated to LoopManagerArrangerFeature (audit §6.6) - // State stays on the modal; the feature holds the methods only. - // ========================================================= - - async _initArrangerSynth() { return this.arrangerFeature?._initArrangerSynth(); } - async _initArrangerTab() { return this.arrangerFeature?._initArrangerTab(); } - _renderArrangerEmptyState() { this.arrangerFeature?._renderArrangerEmptyState(); } - async _purgeEmptyArrangements() { return this.arrangerFeature?._purgeEmptyArrangements(); } - async _loadArrangements() { return this.arrangerFeature?._loadArrangements(); } - _renderArrList(arrs) { this.arrangerFeature?._renderArrList(arrs); } - async _newArrangementConfirm() { return this.arrangerFeature?._newArrangementConfirm(); } - async _requestLoadArrangement(id) { return this.arrangerFeature?._requestLoadArrangement(id); } - async _newArrangement() { return this.arrangerFeature?._newArrangement(); } - async _loadArrangementById(id) { return this.arrangerFeature?._loadArrangementById(id); } - _snapshotArr() { return this.arrangerFeature?._snapshotArr(); } - _resetArrHistory() { this.arrangerFeature?._resetArrHistory(); } - _pushArrHistory() { this.arrangerFeature?._pushArrHistory(); } - _arrUndo() { this.arrangerFeature?._arrUndo(); } - _arrRedo() { this.arrangerFeature?._arrRedo(); } - _restoreArrSnapshot(snap) { this.arrangerFeature?._restoreArrSnapshot(snap); } - _markArrDirty(dirty) { this.arrangerFeature?._markArrDirty(dirty); } - _refreshUndoButtons() { this.arrangerFeature?._refreshUndoButtons(); } - _renderPalette() { this.arrangerFeature?._renderPalette(); } - _renderTimeline() { this.arrangerFeature?._renderTimeline(); } - _renderMinimap() { this.arrangerFeature?._renderMinimap(); } - _renderRuler() { this.arrangerFeature?._renderRuler(); } - _renderArrangerStartMarker() { this.arrangerFeature?._renderArrangerStartMarker(); } - _renderTracks() { this.arrangerFeature?._renderTracks(); } - _buildTrackEl(track) { return this.arrangerFeature?._buildTrackEl(track); } - _isTrackAudible(id) { return this.arrangerFeature?._isTrackAudible(id); } - _toggleTrackMute(id) { this.arrangerFeature?._toggleTrackMute(id); } - _toggleTrackSolo(id) { this.arrangerFeature?._toggleTrackSolo(id); } - _buildCells(trackId, BAR_W) { return this.arrangerFeature?._buildCells(trackId, BAR_W); } - _toggleBlockSelection(id, add) { this.arrangerFeature?._toggleBlockSelection(id, add); } - _clearBlockSelection() { this.arrangerFeature?._clearBlockSelection(); } - _refreshBlockSelectionUI() { this.arrangerFeature?._refreshBlockSelectionUI(); } - async _deleteSelectedBlocks() { return this.arrangerFeature?._deleteSelectedBlocks(); } - _copySelectedBlocks() { this.arrangerFeature?._copySelectedBlocks(); } - async _pasteBlocks(trk, bar) { return this.arrangerFeature?._pasteBlocks(trk, bar); } - async _duplicateSelectedBlocks(){ return this.arrangerFeature?._duplicateSelectedBlocks(); } - _nextFreeBar(trackId) { return this.arrangerFeature?._nextFreeBar(trackId); } - _barWidth() { return this.arrangerFeature?._barWidth() ?? 0; } - _arrZoom(factor) { this.arrangerFeature?._arrZoom(factor); } - _arrZoomReset() { this.arrangerFeature?._arrZoomReset(); } - _arrZoomV(factor) { this.arrangerFeature?._arrZoomV(factor); } - _trackHeight() { return this.arrangerFeature?._trackHeight() ?? 0; } - _toggleLoopPlayback() { this.arrangerFeature?._toggleLoopPlayback(); } - _toggleCountIn() { this.arrangerFeature?._toggleCountIn(); } - _barFromX(offsetX, barW) { return this.arrangerFeature?._barFromX(offsetX, barW); } - _showDropPreview(cells, b, w) { this.arrangerFeature?._showDropPreview(cells, b, w); } - _hideDropPreview() { this.arrangerFeature?._hideDropPreview(); } - async _addTrack() { return this.arrangerFeature?._addTrack(); } - async _deleteTrack(id) { return this.arrangerFeature?._deleteTrack(id); } - async _addBlock(t, l, b, lb) { return this.arrangerFeature?._addBlock(t, l, b, lb); } - async _moveBlock(id, t, b) { return this.arrangerFeature?._moveBlock(id, t, b); } - async _changeReps(id, d) { return this.arrangerFeature?._changeReps(id, d); } - async _deleteBlock(id) { return this.arrangerFeature?._deleteBlock(id); } - async _saveArrangement(opts) { return this.arrangerFeature?._saveArrangement(opts); } - async _deleteArrangement(id) { return this.arrangerFeature?._deleteArrangement(id); } - async _duplicateArrangement(id) { return this.arrangerFeature?._duplicateArrangement(id); } - _adjustArrTempo(d) { this.arrangerFeature?._adjustArrTempo(d); } - _adjustArrBars(d) { this.arrangerFeature?._adjustArrBars(d); } - async _playArrangement(bar) { return this.arrangerFeature?._playArrangement(bar); } - _scheduleCountIn(target, sec) { this.arrangerFeature?._scheduleCountIn(target, sec); } - _stopArrangerPlay() { this.arrangerFeature?._stopArrangerPlay(); } - _startPlaybarRAF() { this.arrangerFeature?._startPlaybarRAF(); } - _stopPlaybarRAF() { this.arrangerFeature?._stopPlaybarRAF(); } - _renderArrangerPlayhead(s) { this.arrangerFeature?._renderArrangerPlayhead(s); } - _scheduleAutoSave(delay) { this.arrangerFeature?._scheduleAutoSave(delay); } - _onDocMouseMove(e) { this.arrangerFeature?._onDocMouseMove(e); } - async _onDocMouseUp() { return this.arrangerFeature?._onDocMouseUp(); } - - - - _renderPlaybar() { return this.view?._renderPlaybar(); } - - - // ========================================================= - // SHARED — FETCH LOOP DATA - // ========================================================= - - async _fetchLoopData(loopId) { - if (this._fetchLoopDataCache.has(loopId)) return this._fetchLoopDataCache.get(loopId); - try { - const r = await this.api.sendCommand('loop_get', { loopId }); - this._fetchLoopDataCache.set(loopId, r.loop); - return r.loop; - } catch (err) { - LoopUtils.handleError(err, 'loop.fetch'); - return null; - } + } + + _onLoopSaved(loopId) { + this._fetchLoopDataCache.delete(loopId); + this._loadLibrary(); + } + + _gmProgramName(prog) { + return this.view?.gmProgramName(prog) ?? `Program ${prog}`; + } + _instrIconHtml(prog, kind, extraClass) { + return this.view?.instrIconHtml(prog, kind, extraClass) ?? ''; + } + + // ========================================================= + // PAD TAB + // ========================================================= + + async _initPadSynth() { + return this.padFeature?.initSynth(); + } + _renderPadGrid() { + this.padFeature?.renderGrid(); + } + _syncPadControls() { + this.padFeature?._syncControls(); + } + _setPadCols(v) { + this.padFeature?.setCols(v); + } + _setPadRows(v) { + this.padFeature?.setRows(v); + } + _adjustPadCols(d) { + this.padFeature?.adjustCols(d); + } + _adjustPadRows(d) { + this.padFeature?.adjustRows(d); + } + _setPadPlayMode(mode) { + this.padFeature?.setPlayMode(mode); + } + _setPadQuantize(q) { + this.padFeature?.setQuantize(q); + } + async _triggerPad(i, opts) { + return this.padFeature?.trigger(i, opts); + } + _stopPad(i) { + this.padFeature?.stop(i); + } + _stopAllPads() { + this.padFeature?.stopAll(); + } + _assignPadSlot(i, loopId) { + this.padFeature?.assignSlot(i, loopId); + } + _openPadPicker(i, anchor) { + this.padFeature?.openPicker(i, anchor); + } + _closePadPicker() { + this.padFeature?.closePicker(); + } + _persistPadLayout() { + this.padFeature?._persist(); + } + _loadPadLayout() { + this.padFeature?.load(); + } + async _clearAllPads() { + return this.padFeature?.clearAll(); + } + + // ========================================================= + // LIVE TAB + // ========================================================= + + async _initLiveSynth() { + return this.liveFeature?.initSynth(); + } + _renderLiveArea() { + this.liveFeature?.renderArea(); + } + async _liveTrigger(id) { + return this.liveFeature?.trigger(id); + } + _liveStop(id) { + this.liveFeature?.stop(id); + } + _liveStopAll() { + this.liveFeature?.stopAll(); + } + // ========================================================= + // ARRANGER — delegated to LoopManagerArrangerFeature (audit §6.6) + // State stays on the modal; the feature holds the methods only. + // ========================================================= + + async _initArrangerSynth() { + return this.arrangerFeature?._initArrangerSynth(); + } + async _initArrangerTab() { + return this.arrangerFeature?._initArrangerTab(); + } + _renderArrangerEmptyState() { + this.arrangerFeature?._renderArrangerEmptyState(); + } + async _purgeEmptyArrangements() { + return this.arrangerFeature?._purgeEmptyArrangements(); + } + async _loadArrangements() { + return this.arrangerFeature?._loadArrangements(); + } + _renderArrList(arrs) { + this.arrangerFeature?._renderArrList(arrs); + } + async _newArrangementConfirm() { + return this.arrangerFeature?._newArrangementConfirm(); + } + async _requestLoadArrangement(id) { + return this.arrangerFeature?._requestLoadArrangement(id); + } + async _newArrangement() { + return this.arrangerFeature?._newArrangement(); + } + async _loadArrangementById(id) { + return this.arrangerFeature?._loadArrangementById(id); + } + _snapshotArr() { + return this.arrangerFeature?._snapshotArr(); + } + _resetArrHistory() { + this.arrangerFeature?._resetArrHistory(); + } + _pushArrHistory() { + this.arrangerFeature?._pushArrHistory(); + } + _arrUndo() { + this.arrangerFeature?._arrUndo(); + } + _arrRedo() { + this.arrangerFeature?._arrRedo(); + } + _restoreArrSnapshot(snap) { + this.arrangerFeature?._restoreArrSnapshot(snap); + } + _markArrDirty(dirty) { + this.arrangerFeature?._markArrDirty(dirty); + } + _refreshUndoButtons() { + this.arrangerFeature?._refreshUndoButtons(); + } + _renderPalette() { + this.arrangerFeature?._renderPalette(); + } + _renderTimeline() { + this.arrangerFeature?._renderTimeline(); + } + _renderMinimap() { + this.arrangerFeature?._renderMinimap(); + } + _renderRuler() { + this.arrangerFeature?._renderRuler(); + } + _renderArrangerStartMarker() { + this.arrangerFeature?._renderArrangerStartMarker(); + } + _renderTracks() { + this.arrangerFeature?._renderTracks(); + } + _buildTrackEl(track) { + return this.arrangerFeature?._buildTrackEl(track); + } + _isTrackAudible(id) { + return this.arrangerFeature?._isTrackAudible(id); + } + _toggleTrackMute(id) { + this.arrangerFeature?._toggleTrackMute(id); + } + _toggleTrackSolo(id) { + this.arrangerFeature?._toggleTrackSolo(id); + } + _buildCells(trackId, BAR_W) { + return this.arrangerFeature?._buildCells(trackId, BAR_W); + } + _toggleBlockSelection(id, add) { + this.arrangerFeature?._toggleBlockSelection(id, add); + } + _clearBlockSelection() { + this.arrangerFeature?._clearBlockSelection(); + } + _refreshBlockSelectionUI() { + this.arrangerFeature?._refreshBlockSelectionUI(); + } + async _deleteSelectedBlocks() { + return this.arrangerFeature?._deleteSelectedBlocks(); + } + _copySelectedBlocks() { + this.arrangerFeature?._copySelectedBlocks(); + } + async _pasteBlocks(trk, bar) { + return this.arrangerFeature?._pasteBlocks(trk, bar); + } + async _duplicateSelectedBlocks() { + return this.arrangerFeature?._duplicateSelectedBlocks(); + } + _nextFreeBar(trackId) { + return this.arrangerFeature?._nextFreeBar(trackId); + } + _barWidth() { + return this.arrangerFeature?._barWidth() ?? 0; + } + _arrZoom(factor) { + this.arrangerFeature?._arrZoom(factor); + } + _arrZoomReset() { + this.arrangerFeature?._arrZoomReset(); + } + _arrZoomV(factor) { + this.arrangerFeature?._arrZoomV(factor); + } + _trackHeight() { + return this.arrangerFeature?._trackHeight() ?? 0; + } + _toggleLoopPlayback() { + this.arrangerFeature?._toggleLoopPlayback(); + } + _toggleCountIn() { + this.arrangerFeature?._toggleCountIn(); + } + _barFromX(offsetX, barW) { + return this.arrangerFeature?._barFromX(offsetX, barW); + } + _showDropPreview(cells, b, w) { + this.arrangerFeature?._showDropPreview(cells, b, w); + } + _hideDropPreview() { + this.arrangerFeature?._hideDropPreview(); + } + async _addTrack() { + return this.arrangerFeature?._addTrack(); + } + async _deleteTrack(id) { + return this.arrangerFeature?._deleteTrack(id); + } + async _addBlock(t, l, b, lb) { + return this.arrangerFeature?._addBlock(t, l, b, lb); + } + async _moveBlock(id, t, b) { + return this.arrangerFeature?._moveBlock(id, t, b); + } + async _changeReps(id, d) { + return this.arrangerFeature?._changeReps(id, d); + } + async _deleteBlock(id) { + return this.arrangerFeature?._deleteBlock(id); + } + async _saveArrangement(opts) { + return this.arrangerFeature?._saveArrangement(opts); + } + async _deleteArrangement(id) { + return this.arrangerFeature?._deleteArrangement(id); + } + async _duplicateArrangement(id) { + return this.arrangerFeature?._duplicateArrangement(id); + } + _adjustArrTempo(d) { + this.arrangerFeature?._adjustArrTempo(d); + } + _adjustArrBars(d) { + this.arrangerFeature?._adjustArrBars(d); + } + async _playArrangement(bar) { + return this.arrangerFeature?._playArrangement(bar); + } + _scheduleCountIn(target, sec) { + this.arrangerFeature?._scheduleCountIn(target, sec); + } + _stopArrangerPlay() { + this.arrangerFeature?._stopArrangerPlay(); + } + _startPlaybarRAF() { + this.arrangerFeature?._startPlaybarRAF(); + } + _stopPlaybarRAF() { + this.arrangerFeature?._stopPlaybarRAF(); + } + _renderArrangerPlayhead(s) { + this.arrangerFeature?._renderArrangerPlayhead(s); + } + _scheduleAutoSave(delay) { + this.arrangerFeature?._scheduleAutoSave(delay); + } + _onDocMouseMove(e) { + this.arrangerFeature?._onDocMouseMove(e); + } + async _onDocMouseUp() { + return this.arrangerFeature?._onDocMouseUp(); + } + + _renderPlaybar() { + return this.view?._renderPlaybar(); + } + + // ========================================================= + // SHARED — FETCH LOOP DATA + // ========================================================= + + async _fetchLoopData(loopId) { + if (this._fetchLoopDataCache.has(loopId)) return this._fetchLoopDataCache.get(loopId); + try { + const r = await this.api.sendCommand('loop_get', { loopId }); + this._fetchLoopDataCache.set(loopId, r.loop); + return r.loop; + } catch (err) { + LoopUtils.handleError(err, 'loop.fetch'); + return null; } - + } } // Expose both names for backward compatibility if (typeof window !== 'undefined') { - window.LoopManagerModal = LoopManagerModal; - window.LoopCreatorModal = LoopManagerModal; + window.LoopManagerModal = LoopManagerModal; + window.LoopCreatorModal = LoopManagerModal; } diff --git a/public/js/features/LoopCreatorModalEvents.js b/public/js/features/LoopCreatorModalEvents.js index 7666bbf96..e862e47e3 100644 --- a/public/js/features/LoopCreatorModalEvents.js +++ b/public/js/features/LoopCreatorModalEvents.js @@ -20,69 +20,117 @@ // ============================================================================ (function () { - 'use strict'; + 'use strict'; - class LoopCreatorModalEvents { - /** @param {LoopCreatorModal} parent */ - constructor(parent) { - this.parent = parent; - } + class LoopCreatorModalEvents { + /** @param {LoopCreatorModal} parent */ + constructor(parent) { + this.parent = parent; + } _onKeyDown(e) { - const t = e.target; - const tag = (t?.tagName || '').toLowerCase(); + const t = e.target; + const tag = (t?.tagName || '').toLowerCase(); - // Navigation clavier dans la tablist (APG tabs pattern, AUDIT §A2). - // Capture en premier — pas conditionné par l'input/textarea check - // car le focus est sur un bouton tab. - if (t?.classList?.contains('lc-tab') && t.getAttribute('role') === 'tab') { - const tabs = ['library', 'pad', 'live', 'keyboard', 'arranger']; - const i = tabs.indexOf(t.dataset.tab); - if (i >= 0) { - let next = -1; - if (e.key === 'ArrowRight') next = (i + 1) % tabs.length; - else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length; - else if (e.key === 'Home') next = 0; - else if (e.key === 'End') next = tabs.length - 1; - if (next >= 0) { - e.preventDefault(); - this._switchTab(tabs[next]); - this.parent.$(`#lc-tab-${tabs[next]}`)?.focus(); - return; - } - } + // Navigation clavier dans la tablist (APG tabs pattern, AUDIT §A2). + // Capture en premier — pas conditionné par l'input/textarea check + // car le focus est sur un bouton tab. + if (t?.classList?.contains('lc-tab') && t.getAttribute('role') === 'tab') { + const tabs = ['library', 'pad', 'live', 'keyboard', 'arranger']; + const i = tabs.indexOf(t.dataset.tab); + if (i >= 0) { + let next = -1; + if (e.key === 'ArrowRight') next = (i + 1) % tabs.length; + else if (e.key === 'ArrowLeft') next = (i - 1 + tabs.length) % tabs.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = tabs.length - 1; + if (next >= 0) { + e.preventDefault(); + this._switchTab(tabs[next]); + this.parent.$(`#lc-tab-${tabs[next]}`)?.focus(); + return; + } } + } - if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; - if (this.parent._loopEditor?.isOpen) return; // editor handles its own shortcuts + if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; + if (this.parent._loopEditor?.isOpen) return; // editor handles its own shortcuts - const mod = e.ctrlKey || e.metaKey; + const mod = e.ctrlKey || e.metaKey; - if (this.parent.activeTab === 'arranger') { - if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); this.parent._arrUndo(); return; } - if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) { e.preventDefault(); this.parent._arrRedo(); return; } - if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); this.parent._arrRedo(); return; } - if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); this.parent._saveArrangement(); return; } - if (e.key === ' ') { e.preventDefault(); this.parent.isArrangerPlaying ? this.parent._stopArrangerPlay() : this.parent._playArrangement(this.parent._arrangerStartBar); return; } - if (e.key === 'Escape') { this.parent._stopArrangerPlay(); this.parent._clearBlockSelection(); return; } - if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent._selectedBlocks.size) { - e.preventDefault(); this.parent._deleteSelectedBlocks(); return; - } - if (mod && e.key.toLowerCase() === 'a') { - e.preventDefault(); - this.parent._selectedBlocks = new Set(this.parent.blocks.map(b => b.id)); - this.parent._refreshBlockSelectionUI(); - return; - } - if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); this.parent._copySelectedBlocks(); return; } - if (mod && e.key.toLowerCase() === 'x') { e.preventDefault(); this.parent._copySelectedBlocks(); this.parent._deleteSelectedBlocks(); return; } - if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); this.parent._pasteBlocks(); return; } - if (mod && e.key.toLowerCase() === 'd') { e.preventDefault(); this.parent._duplicateSelectedBlocks(); return; } + if (this.parent.activeTab === 'arranger') { + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault(); + this.parent._arrUndo(); + return; + } + if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) { + e.preventDefault(); + this.parent._arrRedo(); + return; + } + if (mod && e.key.toLowerCase() === 'y') { + e.preventDefault(); + this.parent._arrRedo(); + return; + } + if (mod && e.key.toLowerCase() === 's') { + e.preventDefault(); + this.parent._saveArrangement(); + return; + } + if (e.key === ' ') { + e.preventDefault(); + this.parent.isArrangerPlaying + ? this.parent._stopArrangerPlay() + : this.parent._playArrangement(this.parent._arrangerStartBar); + return; } + if (e.key === 'Escape') { + this.parent._stopArrangerPlay(); + this.parent._clearBlockSelection(); + return; + } + if ((e.key === 'Delete' || e.key === 'Backspace') && this.parent._selectedBlocks.size) { + e.preventDefault(); + this.parent._deleteSelectedBlocks(); + return; + } + if (mod && e.key.toLowerCase() === 'a') { + e.preventDefault(); + this.parent._selectedBlocks = new Set(this.parent.blocks.map((b) => b.id)); + this.parent._refreshBlockSelectionUI(); + return; + } + if (mod && e.key.toLowerCase() === 'c') { + e.preventDefault(); + this.parent._copySelectedBlocks(); + return; + } + if (mod && e.key.toLowerCase() === 'x') { + e.preventDefault(); + this.parent._copySelectedBlocks(); + this.parent._deleteSelectedBlocks(); + return; + } + if (mod && e.key.toLowerCase() === 'v') { + e.preventDefault(); + this.parent._pasteBlocks(); + return; + } + if (mod && e.key.toLowerCase() === 'd') { + e.preventDefault(); + this.parent._duplicateSelectedBlocks(); + return; + } + } - if (this.parent.activeTab === 'live') { - if (e.key === 'Escape') { this.parent._liveStopAll(); return; } + if (this.parent.activeTab === 'live') { + if (e.key === 'Escape') { + this.parent._liveStopAll(); + return; } + } } // ========================================================= @@ -90,33 +138,37 @@ // ========================================================= _switchTab(tab) { - // Close the pad assignment picker if leaving the Pad tab - if (this.parent.activeTab === 'pad' && tab !== 'pad' && this.parent._padPickerIndex !== null) { - this.parent._closePadPicker(); - } - this.parent.activeTab = tab; - this.parent.$$('.lc-tab').forEach(btn => { - const active = btn.dataset.tab === tab; - btn.classList.toggle('lc-tab--active', active); - btn.setAttribute('aria-selected', active ? 'true' : 'false'); - // APG : tabindex roving — un seul tab focusable à la fois. - btn.setAttribute('tabindex', active ? '0' : '-1'); - }); - this.parent.$$('.lc-pane').forEach(pane => { - pane.classList.toggle('lc-pane--hidden', !pane.id.endsWith(tab)); - }); - const saveBtn = this.parent.$('#lc-header-save'); - if (saveBtn) saveBtn.style.display = tab === 'arranger' ? '' : 'none'; + // Close the pad assignment picker if leaving the Pad tab + if ( + this.parent.activeTab === 'pad' && + tab !== 'pad' && + this.parent._padPickerIndex !== null + ) { + this.parent._closePadPicker(); + } + this.parent.activeTab = tab; + this.parent.$$('.lc-tab').forEach((btn) => { + const active = btn.dataset.tab === tab; + btn.classList.toggle('lc-tab--active', active); + btn.setAttribute('aria-selected', active ? 'true' : 'false'); + // APG : tabindex roving — un seul tab focusable à la fois. + btn.setAttribute('tabindex', active ? '0' : '-1'); + }); + this.parent.$$('.lc-pane').forEach((pane) => { + pane.classList.toggle('lc-pane--hidden', !pane.id.endsWith(tab)); + }); + const saveBtn = this.parent.$('#lc-header-save'); + if (saveBtn) saveBtn.style.display = tab === 'arranger' ? '' : 'none'; - // The embedded keyboard panel can only live in one host at a time, so - // unmount it whenever the user leaves the Keyboard tab. - if (tab !== 'keyboard' && this.parent.keyboard?.mounted) this.parent.keyboard.unmount(); + // The embedded keyboard panel can only live in one host at a time, so + // unmount it whenever the user leaves the Keyboard tab. + if (tab !== 'keyboard' && this.parent.keyboard?.mounted) this.parent.keyboard.unmount(); - if (tab === 'library') this.parent._filterAndRenderLibrary(); - if (tab === 'pad') this.parent._renderPadGrid(); - if (tab === 'live') this.parent._renderLiveArea(); - if (tab === 'keyboard') this.parent.keyboard?.enterTab(); - if (tab === 'arranger') this.parent._initArrangerTab(); + if (tab === 'library') this.parent._filterAndRenderLibrary(); + if (tab === 'pad') this.parent._renderPadGrid(); + if (tab === 'live') this.parent._renderLiveArea(); + if (tab === 'keyboard') this.parent.keyboard?.enterTab(); + if (tab === 'arranger') this.parent._initArrangerTab(); } // ========================================================= @@ -124,197 +176,287 @@ // ========================================================= _attachEvents() { - this.parent.dialog.addEventListener('click', (e) => this._onClick(e)); - this.parent.dialog.addEventListener('change', (e) => this._onChange(e)); - this.parent.dialog.addEventListener('input', (e) => this._onInput(e)); - this.parent.dialog.addEventListener('contextmenu', (e) => this._onContextMenu(e)); + this.parent.dialog.addEventListener('click', (e) => this._onClick(e)); + this.parent.dialog.addEventListener('change', (e) => this._onChange(e)); + this.parent.dialog.addEventListener('input', (e) => this._onInput(e)); + this.parent.dialog.addEventListener('contextmenu', (e) => this._onContextMenu(e)); } _onClick(e) { - // Close pad picker on outside click - if (this.parent._padPickerIndex !== null && !e.target.closest('#lm-pad-picker') && !e.target.closest('.lm-pad-cell')) { - this.parent._closePadPicker(); - } + // Close pad picker on outside click + if ( + this.parent._padPickerIndex !== null && + !e.target.closest('#lm-pad-picker') && + !e.target.closest('.lm-pad-cell') + ) { + this.parent._closePadPicker(); + } - const tabBtn = e.target.closest('.lc-tab[data-tab]'); - if (tabBtn) { this._switchTab(tabBtn.dataset.tab); return; } + const tabBtn = e.target.closest('.lc-tab[data-tab]'); + if (tabBtn) { + this._switchTab(tabBtn.dataset.tab); + return; + } - const btn = e.target.closest('[data-action]'); - if (!btn) return; - const a = btn.dataset.action; - switch (a) { - // Global stop (header button) - case 'stop-all-playback': this.parent._stopAllPads(); this.parent._liveStopAll(); this.parent._stopArrangerPlay(); this.parent.keyboard?.stopAllNotes(); break; - case 'toggle-output': this.parent._toggleHeaderOutput(); break; - // Library - case 'new-loop': this.parent._loopEditor.open(); break; - // Pad - case 'pad-clear-all':this.parent._clearAllPads(); break; - case 'pad-cols-dec': this.parent._adjustPadCols(-1); break; - case 'pad-cols-inc': this.parent._adjustPadCols(+1); break; - case 'pad-rows-dec': this.parent._adjustPadRows(-1); break; - case 'pad-rows-inc': this.parent._adjustPadRows(+1); break; - case 'pad-set-mode': this.parent._setPadPlayMode(btn.dataset.mode); break; - case 'pad-set-quantize': this.parent._setPadQuantize(btn.dataset.quantize); break; - // Live - case 'live-stop-all': this.parent._liveStopAll(); break; - case 'live-trigger': { - const loopId = parseInt(btn.dataset.loopId); - if (!isNaN(loopId)) this.parent._liveTrigger(loopId); - break; - } - // Arranger - case 'arr-tempo-dec': this.parent._adjustArrTempo(-1); break; - case 'arr-tempo-inc': this.parent._adjustArrTempo(+1); break; - case 'arr-bars-dec': this.parent._adjustArrBars(-4); break; - case 'arr-bars-inc': this.parent._adjustArrBars(+4); break; - case 'arr-add-track': this.parent._addTrack(); break; - case 'arr-play': this.parent._playArrangement(this.parent._arrangerStartBar); break; - case 'arr-stop': this.parent._stopArrangerPlay(); break; - case 'arr-new': this.parent._newArrangementConfirm(); break; - case 'arr-zoom-in': this.parent._arrZoom(1.5); break; - case 'arr-zoom-out': this.parent._arrZoom(1 / 1.5); break; - case 'arr-zoom-reset': this.parent._arrZoomReset(); break; - case 'arr-zoomv-in': this.parent._arrZoomV(1.3); break; - case 'arr-zoomv-out': this.parent._arrZoomV(1 / 1.3); break; - case 'arr-toggle-loop': this.parent._toggleLoopPlayback(); break; - case 'arr-toggle-countin': this.parent._toggleCountIn(); break; - case 'arr-undo': this.parent._arrUndo(); break; - case 'arr-redo': this.parent._arrRedo(); break; - case 'save-arrangement': this.parent._saveArrangement(); break; - case 'close': this.parent.close(); break; + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const a = btn.dataset.action; + switch (a) { + // Global stop (header button) + case 'stop-all-playback': + this.parent._stopAllPads(); + this.parent._liveStopAll(); + this.parent._stopArrangerPlay(); + this.parent.keyboard?.stopAllNotes(); + break; + case 'toggle-output': + this.parent._toggleHeaderOutput(); + break; + // Library + case 'new-loop': + this.parent._loopEditor.open(); + break; + // Pad + case 'pad-clear-all': + this.parent._clearAllPads(); + break; + case 'pad-cols-dec': + this.parent._adjustPadCols(-1); + break; + case 'pad-cols-inc': + this.parent._adjustPadCols(+1); + break; + case 'pad-rows-dec': + this.parent._adjustPadRows(-1); + break; + case 'pad-rows-inc': + this.parent._adjustPadRows(+1); + break; + case 'pad-set-mode': + this.parent._setPadPlayMode(btn.dataset.mode); + break; + case 'pad-set-quantize': + this.parent._setPadQuantize(btn.dataset.quantize); + break; + // Live + case 'live-stop-all': + this.parent._liveStopAll(); + break; + case 'live-trigger': { + const loopId = parseInt(btn.dataset.loopId); + if (!isNaN(loopId)) this.parent._liveTrigger(loopId); + break; } + // Arranger + case 'arr-tempo-dec': + this.parent._adjustArrTempo(-1); + break; + case 'arr-tempo-inc': + this.parent._adjustArrTempo(+1); + break; + case 'arr-bars-dec': + this.parent._adjustArrBars(-4); + break; + case 'arr-bars-inc': + this.parent._adjustArrBars(+4); + break; + case 'arr-add-track': + this.parent._addTrack(); + break; + case 'arr-play': + this.parent._playArrangement(this.parent._arrangerStartBar); + break; + case 'arr-stop': + this.parent._stopArrangerPlay(); + break; + case 'arr-new': + this.parent._newArrangementConfirm(); + break; + case 'arr-zoom-in': + this.parent._arrZoom(1.5); + break; + case 'arr-zoom-out': + this.parent._arrZoom(1 / 1.5); + break; + case 'arr-zoom-reset': + this.parent._arrZoomReset(); + break; + case 'arr-zoomv-in': + this.parent._arrZoomV(1.3); + break; + case 'arr-zoomv-out': + this.parent._arrZoomV(1 / 1.3); + break; + case 'arr-toggle-loop': + this.parent._toggleLoopPlayback(); + break; + case 'arr-toggle-countin': + this.parent._toggleCountIn(); + break; + case 'arr-undo': + this.parent._arrUndo(); + break; + case 'arr-redo': + this.parent._arrRedo(); + break; + case 'save-arrangement': + this.parent._saveArrangement(); + break; + case 'close': + this.parent.close(); + break; + } } _onContextMenu(e) { - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (cell) { - e.preventDefault(); - this.parent._openPadPicker(parseInt(cell.dataset.padIndex), cell); - return; - } - const blockEl = e.target.closest('.la-block[data-block-id]'); - if (blockEl) { - e.preventDefault(); - const bid = parseInt(blockEl.dataset.blockId); - if (!this.parent._selectedBlocks.has(bid)) { - this.parent._selectedBlocks.clear(); - this.parent._selectedBlocks.add(bid); - this.parent._refreshBlockSelectionUI(); - } - this._openBlockMenu(e.clientX, e.clientY); + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (cell) { + e.preventDefault(); + this.parent._openPadPicker(parseInt(cell.dataset.padIndex), cell); + return; + } + const blockEl = e.target.closest('.la-block[data-block-id]'); + if (blockEl) { + e.preventDefault(); + const bid = parseInt(blockEl.dataset.blockId); + if (!this.parent._selectedBlocks.has(bid)) { + this.parent._selectedBlocks.clear(); + this.parent._selectedBlocks.add(bid); + this.parent._refreshBlockSelectionUI(); } + this._openBlockMenu(e.clientX, e.clientY); + } } _openBlockMenu(x, y) { - this._closeBlockMenu(); - const menu = document.createElement('div'); - menu.className = 'la-block-menu'; - menu.style.left = x + 'px'; - menu.style.top = y + 'px'; - const items = [ - { action: 'duplicate', label: this.parent.t('loopManager.blockMenuDuplicate'), icon: '⎘' }, - { action: 'copy', label: this.parent.t('loopManager.blockMenuCopy'), icon: '⧉' }, - { action: 'reps-inc', label: this.parent.t('loopManager.blockMenuRepsInc'), icon: '+' }, - { action: 'reps-dec', label: this.parent.t('loopManager.blockMenuRepsDec'), icon: '−' }, - { action: 'delete', label: this.parent.t('loopManager.blockMenuDelete'), icon: '🗑', danger: true } - ]; - menu.innerHTML = items.map(it => + this._closeBlockMenu(); + const menu = document.createElement('div'); + menu.className = 'la-block-menu'; + menu.style.left = x + 'px'; + menu.style.top = y + 'px'; + const items = [ + { action: 'duplicate', label: this.parent.t('loopManager.blockMenuDuplicate'), icon: '⎘' }, + { action: 'copy', label: this.parent.t('loopManager.blockMenuCopy'), icon: '⧉' }, + { action: 'reps-inc', label: this.parent.t('loopManager.blockMenuRepsInc'), icon: '+' }, + { action: 'reps-dec', label: this.parent.t('loopManager.blockMenuRepsDec'), icon: '−' }, + { + action: 'delete', + label: this.parent.t('loopManager.blockMenuDelete'), + icon: '🗑', + danger: true + } + ]; + menu.innerHTML = items + .map( + (it) => `` - ).join(''); - document.body.appendChild(menu); - // Clamp position to viewport - const rect = menu.getBoundingClientRect(); - if (rect.right > window.innerWidth) menu.style.left = (window.innerWidth - rect.width - 4) + 'px'; - if (rect.bottom > window.innerHeight) menu.style.top = (window.innerHeight - rect.height - 4) + 'px'; + ) + .join(''); + document.body.appendChild(menu); + // Clamp position to viewport + const rect = menu.getBoundingClientRect(); + if (rect.right > window.innerWidth) + menu.style.left = window.innerWidth - rect.width - 4 + 'px'; + if (rect.bottom > window.innerHeight) + menu.style.top = window.innerHeight - rect.height - 4 + 'px'; - menu.addEventListener('click', (e) => { - const btn = e.target.closest('[data-menu-action]'); - if (!btn) return; - this._closeBlockMenu(); - const a = btn.dataset.menuAction; - const ids = [...this.parent._selectedBlocks]; - switch (a) { - case 'duplicate': this.parent._duplicateSelectedBlocks(); break; - case 'copy': this.parent._copySelectedBlocks(); break; - case 'reps-inc': ids.forEach(id => this.parent._changeReps(id, +1)); break; - case 'reps-dec': ids.forEach(id => this.parent._changeReps(id, -1)); break; - case 'delete': this.parent._deleteSelectedBlocks(); break; - } - }); - // Dismiss on outside click / escape - this.parent._blockMenuEl = menu; - this.parent._blockMenuDismiss = (ev) => { - if (ev.type === 'keydown' && ev.key !== 'Escape') return; - if (ev.type === 'mousedown' && menu.contains(ev.target)) return; - this._closeBlockMenu(); - }; - setTimeout(() => { - document.addEventListener('mousedown', this.parent._blockMenuDismiss); - document.addEventListener('keydown', this.parent._blockMenuDismiss); - }, 0); + menu.addEventListener('click', (e) => { + const btn = e.target.closest('[data-menu-action]'); + if (!btn) return; + this._closeBlockMenu(); + const a = btn.dataset.menuAction; + const ids = [...this.parent._selectedBlocks]; + switch (a) { + case 'duplicate': + this.parent._duplicateSelectedBlocks(); + break; + case 'copy': + this.parent._copySelectedBlocks(); + break; + case 'reps-inc': + ids.forEach((id) => this.parent._changeReps(id, +1)); + break; + case 'reps-dec': + ids.forEach((id) => this.parent._changeReps(id, -1)); + break; + case 'delete': + this.parent._deleteSelectedBlocks(); + break; + } + }); + // Dismiss on outside click / escape + this.parent._blockMenuEl = menu; + this.parent._blockMenuDismiss = (ev) => { + if (ev.type === 'keydown' && ev.key !== 'Escape') return; + if (ev.type === 'mousedown' && menu.contains(ev.target)) return; + this._closeBlockMenu(); + }; + setTimeout(() => { + document.addEventListener('mousedown', this.parent._blockMenuDismiss); + document.addEventListener('keydown', this.parent._blockMenuDismiss); + }, 0); } _closeBlockMenu() { - if (!this.parent._blockMenuEl) return; - document.removeEventListener('mousedown', this.parent._blockMenuDismiss); - document.removeEventListener('keydown', this.parent._blockMenuDismiss); - this.parent._blockMenuEl.remove(); - this.parent._blockMenuEl = null; - this.parent._blockMenuDismiss = null; + if (!this.parent._blockMenuEl) return; + document.removeEventListener('mousedown', this.parent._blockMenuDismiss); + document.removeEventListener('keydown', this.parent._blockMenuDismiss); + this.parent._blockMenuEl.remove(); + this.parent._blockMenuEl = null; + this.parent._blockMenuDismiss = null; } _onChange(e) { - const id = e.target.id; - if (id === 'lm-lib-filter') { - this.parent.libraryFeature?.setFilter(e.target.value); - } else if (id === 'lm-lib-sort') { - this.parent.libraryFeature?.setSort(e.target.value); - } else if (id === 'lm-pad-cols') { - this.parent._setPadCols(parseInt(e.target.value)); - } else if (id === 'lm-pad-rows') { - this.parent._setPadRows(parseInt(e.target.value)); - } else if (id === 'la-bars') { - const v = LoopUtils.validate.arrBars(e.target.value, this.parent.arrangementBars); - const changed = v !== this.parent.arrangementBars; - this.parent.arrangementBars = v; - e.target.value = v; - if (changed) { - this.parent._renderTimeline(); - this.parent._pushArrHistory(); - this.parent._scheduleAutoSave(); - } + const id = e.target.id; + if (id === 'lm-lib-filter') { + this.parent.libraryFeature?.setFilter(e.target.value); + } else if (id === 'lm-lib-sort') { + this.parent.libraryFeature?.setSort(e.target.value); + } else if (id === 'lm-pad-cols') { + this.parent._setPadCols(parseInt(e.target.value)); + } else if (id === 'lm-pad-rows') { + this.parent._setPadRows(parseInt(e.target.value)); + } else if (id === 'la-bars') { + const v = LoopUtils.validate.arrBars(e.target.value, this.parent.arrangementBars); + const changed = v !== this.parent.arrangementBars; + this.parent.arrangementBars = v; + e.target.value = v; + if (changed) { + this.parent._renderTimeline(); + this.parent._pushArrHistory(); + this.parent._scheduleAutoSave(); } + } } _onInput(e) { - const id = e.target.id; - if (id === 'lm-lib-search') { - this.parent.libraryFeature?.setSearch(e.target.value); - } else if (id === 'lm-live-search') { - this.parent.liveFeature?.setSearch(e.target.value); - } else if (id === 'la-palette-search') { - this.parent._paletteSearch = e.target.value; - this.parent._renderPalette(); - } else if (id === 'la-name-input') { - if (this.parent.arrangementName === e.target.value) return; - this.parent.arrangementName = e.target.value; - this.parent._markArrDirty(true); - this.parent._scheduleAutoSave(); - } else if (id === 'la-tempo') { - const v = LoopUtils.validate.tempo(e.target.value, this.parent.arrangementTempo); - if (v !== this.parent.arrangementTempo) { - this.parent.arrangementTempo = v; - this.parent._markArrDirty(true); - this.parent._scheduleAutoSave(); - } + const id = e.target.id; + if (id === 'lm-lib-search') { + this.parent.libraryFeature?.setSearch(e.target.value); + } else if (id === 'lm-live-search') { + this.parent.liveFeature?.setSearch(e.target.value); + } else if (id === 'la-palette-search') { + this.parent._paletteSearch = e.target.value; + this.parent._renderPalette(); + } else if (id === 'la-name-input') { + if (this.parent.arrangementName === e.target.value) return; + this.parent.arrangementName = e.target.value; + this.parent._markArrDirty(true); + this.parent._scheduleAutoSave(); + } else if (id === 'la-tempo') { + const v = LoopUtils.validate.tempo(e.target.value, this.parent.arrangementTempo); + if (v !== this.parent.arrangementTempo) { + this.parent.arrangementTempo = v; + this.parent._markArrDirty(true); + this.parent._scheduleAutoSave(); } + } } - } + } - if (typeof window !== 'undefined') { - window.LoopCreatorModalEvents = LoopCreatorModalEvents; - } + if (typeof window !== 'undefined') { + window.LoopCreatorModalEvents = LoopCreatorModalEvents; + } })(); diff --git a/public/js/features/LoopCreatorModalView.js b/public/js/features/LoopCreatorModalView.js index a397eebb1..de3819de6 100644 --- a/public/js/features/LoopCreatorModalView.js +++ b/public/js/features/LoopCreatorModalView.js @@ -18,28 +18,28 @@ // ============================================================================ (function () { - 'use strict'; + 'use strict'; - class LoopCreatorModalView { - /** @param {LoopCreatorModal} parent */ - constructor(parent) { - this.parent = parent; - } + class LoopCreatorModalView { + /** @param {LoopCreatorModal} parent */ + constructor(parent) { + this.parent = parent; + } _renderHeader() { - const showSave = this.parent.activeTab === 'arranger'; - return ` + const showSave = this.parent.activeTab === 'arranger'; + return ` `; - } - - renderFooter() { return ''; } - - // ========================================================= - // LIFECYCLE - // ========================================================= - - onOpen() { - this._initSynth(); - this._attachEvents(); - this._initPianoRollEditor(); - this._loadMidiInDevices(); - this._refreshHeaderOutputUI(); - this._mountKeyboardPanel(); - this._attachKeyboardShortcuts(); - this._refreshRecButtonEnabled(); - // Take the baseline snapshot AFTER the piano roll editor has the sequence - requestAnimationFrame(() => this._markSaved()); - } - - _initPianoRollEditor() { - const host = this.$('#le-pane-editor'); - if (!host) return; - - // Prefer the full MIDI editor (mounted as a slim "loop mode" panel) - // when it's available on window — it brings CC/PB/Velocity editing, - // the touch-mode toolbar, and the editor's full piano-roll feature - // set. Falls back to the legacy PianoRollEditor host when the - // class isn't loaded (e.g. minimal pages). - if (typeof window.MidiEditorModal === 'function') { - this.midiEditorPanel = new window.MidiEditorModal(this.eventBus, this.api, { loopMode: true }); - // The panel exposes the editing primitives ; LoopEditorModal's - // existing proxy methods (_refreshPianoRollRange, addNote, - // setCursor, undo, …) keep working through this adapter. - this.pianoRollEditor = this._buildMidiEditorPanelAdapter(this.midiEditorPanel); - // CC persistence isn't wired through loop_create/update yet — - // future work : extend midi_data to carry CC alongside notes - // so the editor can round-trip pitch bend / velocity / CC1…91. - const ccEvents = []; - // showAsPanel is async — wait for the piano-roll element to - // exist before wiring the minimap so we don't race against the - // initial RAF inside MidiEditorModal.initPianoRoll(). - const ready = this.midiEditorPanel.showAsPanel(host, { - sequence: this.sequence, - ccEvents, - tempo: this.tempo, - ppq: this.ppq, - bars: this.bars, - timeSigNum: this.timeSigNum, - timeSigDen: this.timeSigDen, - channel: this._isDrumKit ? 9 : 0, - instrumentProgram: this.instrumentProgram, - onChange: () => { this._syncPanelMinimap(); /* dirty tracked via snapshot */ } - }); - Promise.resolve(ready).then(() => { - this._initPanelMinimap(); - this._initPanelResizeObserver(); - // The piano-roll attribute width is read once from - // clientWidth at mount — if the Editor tab was hidden - // then, the canvas is stuck at the 1000 px fallback even - // after the pane becomes visible. Force a refit on the - // next two animation frames so layout has time to settle. - requestAnimationFrame(() => { - this.pianoRollEditor?.refit?.(); - requestAnimationFrame(() => this.pianoRollEditor?.refit?.()); - }); - }); - return; + } + + renderFooter() { + return ''; + } + + // ========================================================= + // LIFECYCLE + // ========================================================= + + onOpen() { + this._initSynth(); + this._attachEvents(); + this._initPianoRollEditor(); + this._loadMidiInDevices(); + this._refreshHeaderOutputUI(); + this._mountKeyboardPanel(); + this._attachKeyboardShortcuts(); + this._refreshRecButtonEnabled(); + // Take the baseline snapshot AFTER the piano roll editor has the sequence + requestAnimationFrame(() => this._markSaved()); + } + + _initPianoRollEditor() { + const host = this.$('#le-pane-editor'); + if (!host) return; + + // Prefer the full MIDI editor (mounted as a slim "loop mode" panel) + // when it's available on window — it brings CC/PB/Velocity editing, + // the touch-mode toolbar, and the editor's full piano-roll feature + // set. Falls back to the legacy PianoRollEditor host when the + // class isn't loaded (e.g. minimal pages). + if (typeof window.MidiEditorModal === 'function') { + this.midiEditorPanel = new window.MidiEditorModal(this.eventBus, this.api, { + loopMode: true + }); + // The panel exposes the editing primitives ; LoopEditorModal's + // existing proxy methods (_refreshPianoRollRange, addNote, + // setCursor, undo, …) keep working through this adapter. + this.pianoRollEditor = this._buildMidiEditorPanelAdapter(this.midiEditorPanel); + // CC persistence isn't wired through loop_create/update yet — + // future work : extend midi_data to carry CC alongside notes + // so the editor can round-trip pitch bend / velocity / CC1…91. + const ccEvents = []; + // showAsPanel is async — wait for the piano-roll element to + // exist before wiring the minimap so we don't race against the + // initial RAF inside MidiEditorModal.initPianoRoll(). + const ready = this.midiEditorPanel.showAsPanel(host, { + sequence: this.sequence, + ccEvents, + tempo: this.tempo, + ppq: this.ppq, + bars: this.bars, + timeSigNum: this.timeSigNum, + timeSigDen: this.timeSigDen, + channel: this._isDrumKit ? 9 : 0, + instrumentProgram: this.instrumentProgram, + onChange: () => { + this._syncPanelMinimap(); /* dirty tracked via snapshot */ } - - // Legacy fallback — keeps existing tests/pages running. - const minimapCanvas = this.$('#le-minimap-top'); - this.pianoRollEditor = new window.PianoRollEditor(host, { - t: (key, params) => this.t(key, params), - initial: { - sequence: this.sequence, - ppq: this.ppq, - tempo: this.tempo, - bars: this.bars, - timeSigNum: this.timeSigNum, - timeSigDen: this.timeSigDen, - noteMin: this.outputNoteMin, - noteMax: this.outputNoteMax - }, - externalMinimapEl: minimapCanvas, - minimapReadOnly: true, - getStatusEl: () => this.$('#lc-status') - }); - this.pianoRollEditor.mount(); - // If we open straight into the Editor tab, give the layout a tick - // before fitting so flex sizes settle. - if (this.activeTab === 'editor') { - requestAnimationFrame(() => this.pianoRollEditor.refit()); - } - } - - /** - * Wrap MidiEditorModal (panel mode) behind the API LoopEditorModal - * already calls on the previous PianoRollEditor : getSequence, addNote, - * setRange, setCursor, undo/redo, copy/paste, etc. Keeps the rest of - * LoopEditorModal unchanged while swapping the underlying editor. - */ - _buildMidiEditorPanelAdapter(panel) { - const owner = this; - return { - get host() { return panel.container; }, - getSequence: () => panel.getPanelLoopState().sequence, - setRange: ({ tempo, bars, ppq, timeSigNum, timeSigDen, noteMin, noteMax } = {}) => { - panel.setPanelLoopState({ tempo, bars, ppq, timeSigNum, timeSigDen }); - owner._syncPanelMinimap(); - // noteMin/noteMax are advisory ; the editor lets the user - // scroll/zoom freely so we don't clamp. - void noteMin; void noteMax; - }, - setCursor: (tick) => { - if (panel.pianoRoll) panel.pianoRoll.cursor = tick ?? 0; - owner._panelMinimap?.setPlayhead(tick ?? 0, owner.isPlaying); - }, - setRecordingPlayhead: (tick) => { - if (panel.pianoRoll) panel.pianoRoll.cursor = tick ?? 0; - owner._panelMinimap?.setPlayhead(tick ?? 0, tick != null); - }, - setMode: (mode) => { - const map = { view: 'drag-view', select: 'select', dragpoly: 'edit' }; - panel.editActions?.setEditMode?.(map[mode] || mode); - }, - undo: () => panel.editActions?.undo?.(), - redo: () => panel.editActions?.redo?.(), - selectAll: () => panel.editActions?.selectAll?.(), - copy: () => panel.editActions?.copy?.(), - paste: () => panel.editActions?.paste?.(), - deleteSelected: () => panel.editActions?.deleteSelectedNotes?.(), - addNote: (noteObj) => { - // Push the note directly into the live piano-roll sequence - // so recording stays incremental (rebuilding the whole array - // would drop the user's selection / view state). - if (!panel.pianoRoll) return; - const ch = panel.channels?.[0]?.channel ?? 0; - const seq = Array.isArray(panel.pianoRoll.sequence) ? panel.pianoRoll.sequence : []; - seq.push({ ...noteObj, c: ch }); - panel.pianoRoll.sequence = seq; - panel.fullSequence = seq.map(n => ({ ...n })); - panel.sequence = panel.fullSequence; - panel.pianoRoll.redraw?.(); - panel.isDirty = true; - owner._syncPanelMinimap(); - }, - refit: () => { - // Canvas pixel sizing now lives on MidiEditorModal itself - // (auto-fires via its own ResizeObserver). We just nudge - // a redraw and resync the loop minimap. - panel.routingOps?._refreshPianoRollSize?.(); - panel.pianoRoll?.redraw?.(); - owner._syncPanelMinimap(); - }, - destroy: () => { - owner._teardownPanelMinimap(); - owner._teardownPanelResizeObserver(); - panel.unmountPanel?.(); - } - }; - } - - /** - * Drive the loop editor's `#le-minimap-top` canvas from the embedded - * MidiEditor panel's webaudio-pianoroll. Replaces what PianoRollEditor - * used to do via its `externalMinimapEl` option. - */ - _initPanelMinimap() { - const canvas = this.$('#le-minimap-top'); - const panel = this.midiEditorPanel; - if (!canvas || !panel?.pianoRoll || typeof window.LoopCreatorMinimap !== 'function') return; - - this._panelMinimap = new window.LoopCreatorMinimap(canvas, { - ppq: this.ppq, - timeSigNum: this.timeSigNum, - bars: this.bars, - noteMin: this.outputNoteMin, - noteMax: this.outputNoteMax, - onSeek: null // read-only — the panel owns navigation - }); - canvas.style.cursor = 'default'; - canvas.style.pointerEvents = 'none'; - - const pr = panel.pianoRoll; - this._panelMinimapObserver = new MutationObserver(() => this._syncPanelMinimap()); - this._panelMinimapObserver.observe(pr, { - attributes: true, attributeFilter: ['xoffset', 'xrange'] - }); - this._panelMinimapChange = () => this._syncPanelMinimap(); - pr.addEventListener('change', this._panelMinimapChange); - - // The minimap canvas reads `clientWidth/Height` in its `draw()` - // and bails out early if either is 0 — which is exactly what - // happens at first paint while the transport bar's flex layout - // is still settling. Without the resize observer below, the - // minimap stays stuck at its first (possibly partial) width - // until the user nudges the tempo or otherwise re-triggers a - // sync. The observer re-renders on every canvas size change. - if (typeof ResizeObserver !== 'undefined') { - let lastW = 0, lastH = 0; - this._panelMinimapCanvasObs = new ResizeObserver(() => { - const w = canvas.clientWidth, h = canvas.clientHeight; - if (w === lastW && h === lastH) return; - lastW = w; lastH = h; - this._panelMinimap?.draw?.(); - }); - this._panelMinimapCanvasObs.observe(canvas); - } - this._syncPanelMinimap(); - } - - _syncPanelMinimap() { - const m = this._panelMinimap; - const pr = this.midiEditorPanel?.pianoRoll; - if (!m || !pr) return; - const xoff = parseFloat(pr.getAttribute('xoffset') || 0); - const xrange = parseFloat(pr.getAttribute('xrange') || 0); - m.setConfig({ - ppq: this.ppq, - timeSigNum: this.timeSigNum, - bars: this.bars, - noteMin: this.outputNoteMin, - noteMax: this.outputNoteMax - }); - m.setNotes(pr.sequence ?? []); - m.setViewport(xoff, xrange); - } - - /** - * The MidiEditor panel reads `clientWidth` / `clientHeight` from the - * piano-roll container once, at mount time. If the Editor tab was - * hidden then (new-loop default tab is Piano), clientWidth is 0 and - * the canvas falls back to a 1000 px width — that's the "right edge - * stuck at 2/3 of the page" the user reports. A ResizeObserver on - * the container refits as soon as it gets real dimensions. - */ - _initPanelResizeObserver() { - if (this._panelResizeObs) return; - const container = this.midiEditorPanel?.container?.querySelector('#piano-roll-container'); - if (!container || typeof ResizeObserver === 'undefined') return; - let lastW = 0, lastH = 0; - this._panelResizeObs = new ResizeObserver(() => { - const w = container.clientWidth; - const h = container.clientHeight; - if (w === lastW && h === lastH) return; - lastW = w; lastH = h; - this.pianoRollEditor?.refit?.(); + }); + Promise.resolve(ready).then(() => { + this._initPanelMinimap(); + this._initPanelResizeObserver(); + // The piano-roll attribute width is read once from + // clientWidth at mount — if the Editor tab was hidden + // then, the canvas is stuck at the 1000 px fallback even + // after the pane becomes visible. Force a refit on the + // next two animation frames so layout has time to settle. + requestAnimationFrame(() => { + this.pianoRollEditor?.refit?.(); + requestAnimationFrame(() => this.pianoRollEditor?.refit?.()); }); - this._panelResizeObs.observe(container); - } - - _teardownPanelResizeObserver() { - if (this._panelResizeObs) { - try { this._panelResizeObs.disconnect(); } catch (_) { /* best-effort */ } - this._panelResizeObs = null; - } - } - - _teardownPanelMinimap() { - if (this._panelMinimapObserver) { - try { this._panelMinimapObserver.disconnect(); } catch (_) { /* best-effort */ } - this._panelMinimapObserver = null; - } - if (this._panelMinimapCanvasObs) { - try { this._panelMinimapCanvasObs.disconnect(); } catch (_) { /* best-effort */ } - this._panelMinimapCanvasObs = null; - } - if (this._panelMinimapChange && this.midiEditorPanel?.pianoRoll) { - try { this.midiEditorPanel.pianoRoll.removeEventListener('change', this._panelMinimapChange); } - catch (_) { /* best-effort */ } - } - this._panelMinimapChange = null; - if (this._panelMinimap) { - try { this._panelMinimap.destroy?.(); } catch (_) { /* best-effort */ } - this._panelMinimap = null; - } - } - - /** - * Two-state output toggle in the header : - * • 'synth' → preview through the local in-browser synth - * • 'instruments' → send MIDI messages to the connected instrument(s) - * (the actual device is the one chosen via the - * embedded keyboard's instrument selector, or any - * device routed at the session level). - * The previous device dropdown was removed at the user's request. - */ - _refreshHeaderOutputUI() { - const btn = this.$('#le-output-toggle'); - const icon = this.$('#le-output-icon'); - const label = this.$('#le-output-label'); - const isDev = this._outputTarget === 'live'; - if (btn) { - btn.classList.toggle('le-output-toggle--device', isDev); - btn.setAttribute('aria-pressed', isDev ? 'true' : 'false'); - } - if (icon) icon.textContent = isDev ? '🔌' : '🔊'; - if (label) label.textContent = isDev - ? this.t('loopCreator.outputLive') - : this.t('loopManager.outputSynth'); - } - - close() { - if (!this.isOpen || !this._isDirty()) { - super.close(); - return; - } - // Confirmation accessible (modale stylable + focus trap) au lieu - // du window.confirm natif (AUDIT §A1). Le close() reste synchrone - // côté API publique ; la branche async ne fait qu'appeler super - // après acceptation utilisateur. - const doSuperClose = () => super.close(); - LoopUtils.confirm(this.t('loopEditor.confirmDiscardChanges'), { - icon: '⚠️', - title: this.t('loopEditor.confirmDiscardTitle') || this.t('loopEditor.confirmDiscardChanges'), - danger: true - }).then((ok) => { if (ok) doSuperClose(); }); - } - - onClose() { - this._detachKeyboardShortcuts(); - this._unmountKeyboardPanel(); + }); + return; + } + + // Legacy fallback — keeps existing tests/pages running. + const minimapCanvas = this.$('#le-minimap-top'); + this.pianoRollEditor = new window.PianoRollEditor(host, { + t: (key, params) => this.t(key, params), + initial: { + sequence: this.sequence, + ppq: this.ppq, + tempo: this.tempo, + bars: this.bars, + timeSigNum: this.timeSigNum, + timeSigDen: this.timeSigDen, + noteMin: this.outputNoteMin, + noteMax: this.outputNoteMax + }, + externalMinimapEl: minimapCanvas, + minimapReadOnly: true, + getStatusEl: () => this.$('#lc-status') + }); + this.pianoRollEditor.mount(); + // If we open straight into the Editor tab, give the layout a tick + // before fitting so flex sizes settle. + if (this.activeTab === 'editor') { + requestAnimationFrame(() => this.pianoRollEditor.refit()); + } + } + + /** + * Wrap MidiEditorModal (panel mode) behind the API LoopEditorModal + * already calls on the previous PianoRollEditor : getSequence, addNote, + * setRange, setCursor, undo/redo, copy/paste, etc. Keeps the rest of + * LoopEditorModal unchanged while swapping the underlying editor. + */ + _buildMidiEditorPanelAdapter(panel) { + const owner = this; + return { + get host() { + return panel.container; + }, + getSequence: () => panel.getPanelLoopState().sequence, + setRange: ({ tempo, bars, ppq, timeSigNum, timeSigDen, noteMin, noteMax } = {}) => { + panel.setPanelLoopState({ tempo, bars, ppq, timeSigNum, timeSigDen }); + owner._syncPanelMinimap(); + // noteMin/noteMax are advisory ; the editor lets the user + // scroll/zoom freely so we don't clamp. + void noteMin; + void noteMax; + }, + setCursor: (tick) => { + if (panel.pianoRoll) panel.pianoRoll.cursor = tick ?? 0; + owner._panelMinimap?.setPlayhead(tick ?? 0, owner.isPlaying); + }, + setRecordingPlayhead: (tick) => { + if (panel.pianoRoll) panel.pianoRoll.cursor = tick ?? 0; + owner._panelMinimap?.setPlayhead(tick ?? 0, tick != null); + }, + setMode: (mode) => { + const map = { view: 'drag-view', select: 'select', dragpoly: 'edit' }; + panel.editActions?.setEditMode?.(map[mode] || mode); + }, + undo: () => panel.editActions?.undo?.(), + redo: () => panel.editActions?.redo?.(), + selectAll: () => panel.editActions?.selectAll?.(), + copy: () => panel.editActions?.copy?.(), + paste: () => panel.editActions?.paste?.(), + deleteSelected: () => panel.editActions?.deleteSelectedNotes?.(), + addNote: (noteObj) => { + // Push the note directly into the live piano-roll sequence + // so recording stays incremental (rebuilding the whole array + // would drop the user's selection / view state). + if (!panel.pianoRoll) return; + const ch = panel.channels?.[0]?.channel ?? 0; + const seq = Array.isArray(panel.pianoRoll.sequence) ? panel.pianoRoll.sequence : []; + seq.push({ ...noteObj, c: ch }); + panel.pianoRoll.sequence = seq; + panel.fullSequence = seq.map((n) => ({ ...n })); + panel.sequence = panel.fullSequence; + panel.pianoRoll.redraw?.(); + panel.isDirty = true; + owner._syncPanelMinimap(); + }, + refit: () => { + // Canvas pixel sizing now lives on MidiEditorModal itself + // (auto-fires via its own ResizeObserver). We just nudge + // a redraw and resync the loop minimap. + panel.routingOps?._refreshPianoRollSize?.(); + panel.pianoRoll?.redraw?.(); + owner._syncPanelMinimap(); + }, + destroy: () => { + owner._teardownPanelMinimap(); + owner._teardownPanelResizeObserver(); + panel.unmountPanel?.(); + } + }; + } + + /** + * Drive the loop editor's `#le-minimap-top` canvas from the embedded + * MidiEditor panel's webaudio-pianoroll. Replaces what PianoRollEditor + * used to do via its `externalMinimapEl` option. + */ + _initPanelMinimap() { + const canvas = this.$('#le-minimap-top'); + const panel = this.midiEditorPanel; + if (!canvas || !panel?.pianoRoll || typeof window.LoopCreatorMinimap !== 'function') return; + + this._panelMinimap = new window.LoopCreatorMinimap(canvas, { + ppq: this.ppq, + timeSigNum: this.timeSigNum, + bars: this.bars, + noteMin: this.outputNoteMin, + noteMax: this.outputNoteMax, + onSeek: null // read-only — the panel owns navigation + }); + canvas.style.cursor = 'default'; + canvas.style.pointerEvents = 'none'; + + const pr = panel.pianoRoll; + this._panelMinimapObserver = new MutationObserver(() => this._syncPanelMinimap()); + this._panelMinimapObserver.observe(pr, { + attributes: true, + attributeFilter: ['xoffset', 'xrange'] + }); + this._panelMinimapChange = () => this._syncPanelMinimap(); + pr.addEventListener('change', this._panelMinimapChange); + + // The minimap canvas reads `clientWidth/Height` in its `draw()` + // and bails out early if either is 0 — which is exactly what + // happens at first paint while the transport bar's flex layout + // is still settling. Without the resize observer below, the + // minimap stays stuck at its first (possibly partial) width + // until the user nudges the tempo or otherwise re-triggers a + // sync. The observer re-renders on every canvas size change. + if (typeof ResizeObserver !== 'undefined') { + let lastW = 0, + lastH = 0; + this._panelMinimapCanvasObs = new ResizeObserver(() => { + const w = canvas.clientWidth, + h = canvas.clientHeight; + if (w === lastW && h === lastH) return; + lastW = w; + lastH = h; + this._panelMinimap?.draw?.(); + }); + this._panelMinimapCanvasObs.observe(canvas); + } + this._syncPanelMinimap(); + } + + _syncPanelMinimap() { + const m = this._panelMinimap; + const pr = this.midiEditorPanel?.pianoRoll; + if (!m || !pr) return; + const xoff = parseFloat(pr.getAttribute('xoffset') || 0); + const xrange = parseFloat(pr.getAttribute('xrange') || 0); + m.setConfig({ + ppq: this.ppq, + timeSigNum: this.timeSigNum, + bars: this.bars, + noteMin: this.outputNoteMin, + noteMax: this.outputNoteMax + }); + m.setNotes(pr.sequence ?? []); + m.setViewport(xoff, xrange); + } + + /** + * The MidiEditor panel reads `clientWidth` / `clientHeight` from the + * piano-roll container once, at mount time. If the Editor tab was + * hidden then (new-loop default tab is Piano), clientWidth is 0 and + * the canvas falls back to a 1000 px width — that's the "right edge + * stuck at 2/3 of the page" the user reports. A ResizeObserver on + * the container refits as soon as it gets real dimensions. + */ + _initPanelResizeObserver() { + if (this._panelResizeObs) return; + const container = this.midiEditorPanel?.container?.querySelector('#piano-roll-container'); + if (!container || typeof ResizeObserver === 'undefined') return; + let lastW = 0, + lastH = 0; + this._panelResizeObs = new ResizeObserver(() => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === lastW && h === lastH) return; + lastW = w; + lastH = h; + this.pianoRollEditor?.refit?.(); + }); + this._panelResizeObs.observe(container); + } + + _teardownPanelResizeObserver() { + if (this._panelResizeObs) { + try { + this._panelResizeObs.disconnect(); + } catch (_) { + /* best-effort */ + } + this._panelResizeObs = null; + } + } + + _teardownPanelMinimap() { + if (this._panelMinimapObserver) { + try { + this._panelMinimapObserver.disconnect(); + } catch (_) { + /* best-effort */ + } + this._panelMinimapObserver = null; + } + if (this._panelMinimapCanvasObs) { + try { + this._panelMinimapCanvasObs.disconnect(); + } catch (_) { + /* best-effort */ + } + this._panelMinimapCanvasObs = null; + } + if (this._panelMinimapChange && this.midiEditorPanel?.pianoRoll) { + try { + this.midiEditorPanel.pianoRoll.removeEventListener('change', this._panelMinimapChange); + } catch (_) { + /* best-effort */ + } + } + this._panelMinimapChange = null; + if (this._panelMinimap) { + try { + this._panelMinimap.destroy?.(); + } catch (_) { + /* best-effort */ + } + this._panelMinimap = null; + } + } + + /** + * Two-state output toggle in the header : + * • 'synth' → preview through the local in-browser synth + * • 'instruments' → send MIDI messages to the connected instrument(s) + * (the actual device is the one chosen via the + * embedded keyboard's instrument selector, or any + * device routed at the session level). + * The previous device dropdown was removed at the user's request. + */ + _refreshHeaderOutputUI() { + const btn = this.$('#le-output-toggle'); + const icon = this.$('#le-output-icon'); + const label = this.$('#le-output-label'); + const isDev = this._outputTarget === 'live'; + if (btn) { + btn.classList.toggle('le-output-toggle--device', isDev); + btn.setAttribute('aria-pressed', isDev ? 'true' : 'false'); + } + if (icon) icon.textContent = isDev ? '🔌' : '🔊'; + if (label) + label.textContent = isDev + ? this.t('loopCreator.outputLive') + : this.t('loopManager.outputSynth'); + } + + close() { + if (!this.isOpen || !this._isDirty()) { + super.close(); + return; + } + // Confirmation accessible (modale stylable + focus trap) au lieu + // du window.confirm natif (AUDIT §A1). Le close() reste synchrone + // côté API publique ; la branche async ne fait qu'appeler super + // après acceptation utilisateur. + const doSuperClose = () => super.close(); + LoopUtils.confirm(this.t('loopEditor.confirmDiscardChanges'), { + icon: '⚠️', + title: this.t('loopEditor.confirmDiscardTitle') || this.t('loopEditor.confirmDiscardChanges'), + danger: true + }).then((ok) => { + if (ok) doSuperClose(); + }); + } + + onClose() { + this._detachKeyboardShortcuts(); + this._unmountKeyboardPanel(); + this._stopAll(); + this._stopRecordingAnimation(); + this._stopMidiInMonitor(); + this._stopMetronome(); + this._countInActive = false; + if (this.pianoRollEditor) { + this.pianoRollEditor.destroy(); + this.pianoRollEditor = null; + } + if (this.midiEditorPanel) { + try { + this.midiEditorPanel.unmountPanel(); + } catch (_) { + /* best-effort */ + } + this.midiEditorPanel = null; + } + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + this._refreshTimer = null; + } + // Libère le AudioContext du métronome — sinon le navigateur + // plafonne à ~6 contextes par tab et le métronome reste muet + // après plusieurs cycles open/close (AUDIT §L6). + // close() retourne une Promise : on swallow l'éventuel reject + // (déjà fermé, état invalide…) pour éviter un unhandledRejection. + if (this._metronomeCtx) { + try { + const p = this._metronomeCtx.close?.(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } catch (_) {} + this._metronomeCtx = null; + } + } + + // ========================================================= + // EVENTS + // ========================================================= + + _attachEvents() { + this.dialog.addEventListener('click', (e) => this._onClick(e)); + this.dialog.addEventListener('change', (e) => this._onChange(e)); + this.dialog.addEventListener('input', (e) => this._onInput(e)); + } + + _onClick(e) { + // Tab switch + const tabBtn = e.target.closest('.le-tab[data-le-tab]'); + if (tabBtn) { + this._switchTab(tabBtn.dataset.leTab); + return; + } + + const btn = e.target.closest('[data-action]'); + if (!btn) return; + switch (btn.dataset.action) { + case 'tempo-dec': + this._adjustTempo(-1); + break; + case 'tempo-inc': + this._adjustTempo(+1); + break; + case 'bars-dec': + this._adjustBars(-1); + break; + case 'bars-inc': + this._adjustBars(+1); + break; + case 'toggle-output': + this._toggleOutput(); + break; + case 'record': + this._toggleRecording(); + break; + case 'preview': + this._previewLoop(); + break; + case 'stop-all': this._stopAll(); - this._stopRecordingAnimation(); - this._stopMidiInMonitor(); - this._stopMetronome(); - this._countInActive = false; - if (this.pianoRollEditor) { - this.pianoRollEditor.destroy(); - this.pianoRollEditor = null; - } - if (this.midiEditorPanel) { - try { this.midiEditorPanel.unmountPanel(); } catch (_) { /* best-effort */ } - this.midiEditorPanel = null; - } - if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } - // Libère le AudioContext du métronome — sinon le navigateur - // plafonne à ~6 contextes par tab et le métronome reste muet - // après plusieurs cycles open/close (AUDIT §L6). - // close() retourne une Promise : on swallow l'éventuel reject - // (déjà fermé, état invalide…) pour éviter un unhandledRejection. - if (this._metronomeCtx) { - try { - const p = this._metronomeCtx.close?.(); - if (p && typeof p.catch === 'function') p.catch(() => {}); - } catch (_) {} - this._metronomeCtx = null; - } - } - - // ========================================================= - // EVENTS - // ========================================================= - - _attachEvents() { - this.dialog.addEventListener('click', (e) => this._onClick(e)); - this.dialog.addEventListener('change', (e) => this._onChange(e)); - this.dialog.addEventListener('input', (e) => this._onInput(e)); - } - - _onClick(e) { - // Tab switch - const tabBtn = e.target.closest('.le-tab[data-le-tab]'); - if (tabBtn) { this._switchTab(tabBtn.dataset.leTab); return; } - - const btn = e.target.closest('[data-action]'); - if (!btn) return; - switch (btn.dataset.action) { - case 'tempo-dec': this._adjustTempo(-1); break; - case 'tempo-inc': this._adjustTempo(+1); break; - case 'bars-dec': this._adjustBars(-1); break; - case 'bars-inc': this._adjustBars(+1); break; - case 'toggle-output': this._toggleOutput(); break; - case 'record': this._toggleRecording(); break; - case 'preview': this._previewLoop(); break; - case 'stop-all': this._stopAll(); break; - case 'save-loop': this._saveLoop(); break; - case 'save-loop-as-new': this._saveLoop({ asNew: true }); break; - case 'toggle-metronome': this._toggleMetronome(); break; - case 'toggle-count-in': this._toggleCountIn(); break; - case 'close': this.close(); break; - } - } - - _switchTab(tab) { - if (tab !== 'piano' && tab !== 'editor') return; - if (this.activeTab === tab) return; - this.activeTab = tab; - this.$$('.le-tab').forEach(b => { - const on = b.dataset.leTab === tab; - b.classList.toggle('le-tab--active', on); - b.setAttribute('aria-selected', on ? 'true' : 'false'); - }); - const piano = this.$('#le-pane-piano'); - const editor = this.$('#le-pane-editor'); - if (piano) piano.classList.toggle('le-pane--hidden', tab !== 'piano'); - if (editor) editor.classList.toggle('le-pane--hidden', tab !== 'editor'); - if (tab === 'editor' && this.pianoRollEditor) { - requestAnimationFrame(() => this.pianoRollEditor.refit()); - } - if (tab === 'piano') { - // Re-probe keyboards each time the user comes back to the Piano - // tab, in case they plugged in a keyboard since the modal opened. - this._loadMidiInDevices(); - } - } - - _onChange(e) { - const id = e.target.id; - if (id === 'lc-timesig') { - const [num, den] = e.target.value.split(':').map(Number); - this.timeSigNum = num; this.timeSigDen = den; - this._refreshPianoRollRange(); - } else if (id === 'lc-midi-in-device') { - this._midiInDevice = e.target.value || null; - } - } - - /** - * Enable / disable the REC button depending on whether the user has - * picked an instrument. Disabled record button gets a hint title + - * `aria-disabled` so screen readers don't fire it either. - */ - _refreshRecButtonEnabled() { - const btn = this.$('#lc-record-btn'); - if (!btn) return; - const ok = this._instrumentSelected === true; - btn.disabled = !ok; - btn.setAttribute('aria-disabled', ok ? 'false' : 'true'); - btn.title = ok - ? this.t('loopCreator.record') - : (this.t('loopEditor.chooseInstrumentFirst') || 'Choisissez un instrument avant d\'enregistrer'); - btn.classList.toggle('le-rec-big--disabled', !ok); - } - - _onInput(e) { - const id = e.target.id; - if (id === 'lc-tempo') { - const v = LoopUtils.validate.tempo(e.target.value, this.tempo); - if (v !== this.tempo) { this.tempo = v; this._scheduleRefreshRange(); } - } else if (id === 'lc-bars') { - const v = LoopUtils.validate.editorBars(e.target.value, this.bars); - if (v !== this.bars) { this.bars = v; this._scheduleRefreshRange(); } - } else if (id === 'lc-name-input') { - this.loopName = e.target.value; - } - } - - _scheduleRefreshRange() { - if (this._refreshTimer) clearTimeout(this._refreshTimer); - this._refreshTimer = setTimeout(() => { - this._refreshTimer = null; - this._refreshPianoRollRange(); - }, 200); - } - - // ========================================================= - // KEYBOARD SHORTCUTS - // ========================================================= - - _attachKeyboardShortcuts() { - if (this._keyHandler) return; - this._keyHandler = (e) => { - // Don't intercept while typing in a text/number input - const t = e.target; - const tag = (t?.tagName || '').toLowerCase(); - if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; - if (!this.isOpen) return; - - const mod = e.ctrlKey || e.metaKey; - // Only act if the editor modal is the front-most loop modal - const front = document.querySelector('.modal-overlay:not(.hidden) .loop-editor-modal'); - if (!front || !front.contains(this.dialog || front)) { - // Defensive: if our dialog isn't visible, skip - if (!this.dialog || !this.dialog.offsetParent) return; - } - - const ed = this.pianoRollEditor; - if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); ed?.undo?.(); return; } - if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) { e.preventDefault(); ed?.redo?.(); return; } - if (mod && e.key.toLowerCase() === 'y') { e.preventDefault(); ed?.redo?.(); return; } - if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); ed?.selectAll?.(); return; } - if (mod && e.key.toLowerCase() === 'c') { e.preventDefault(); ed?.copy?.(); return; } - if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); ed?.paste?.(); return; } - if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); this._saveLoop(); return; } - if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); ed?.deleteSelected?.(); return; } - if (e.key === ' ') { e.preventDefault(); this.isPlaying ? this._stopAll() : this._previewLoop(); return; } - if (e.key === 'Escape') { this._stopAll(); return; } - if (e.key.toLowerCase() === 'r') { this._toggleRecording(); return; } - if (e.key === 'Enter') { e.preventDefault(); this._toggleRecording(); return; } - // Mode shortcuts (no modifier): V/S/D - if (e.key.toLowerCase() === 'v' && !mod) { ed?.setMode?.('view'); return; } - if (e.key.toLowerCase() === 's' && !mod && !e.shiftKey){ ed?.setMode?.('select'); return; } - if (e.key.toLowerCase() === 'd' && !mod) { ed?.setMode?.('dragpoly'); return; } - }; - document.addEventListener('keydown', this._keyHandler); - } - - _detachKeyboardShortcuts() { - if (this._keyHandler) { - document.removeEventListener('keydown', this._keyHandler); - this._keyHandler = null; - } - } - - // ========================================================= - // PIANO ROLL — thin proxies to PianoRollEditor - // ========================================================= - - _refreshPianoRollRange() { - this.pianoRollEditor?.setRange({ - tempo: this.tempo, - bars: this.bars, - ppq: this.ppq, - timeSigNum: this.timeSigNum, - timeSigDen: this.timeSigDen, - noteMin: this.outputNoteMin, - noteMax: this.outputNoteMax - }); - } - - _adjustTempo(d) { - this.tempo = LoopUtils.validate.tempo(this.tempo + d, this.tempo); - const el = this.$('#lc-tempo'); if (el) el.value = this.tempo; - this._refreshPianoRollRange(); - } - - _adjustBars(d) { - this.bars = LoopUtils.validate.editorBars(this.bars + d, this.bars); - const el = this.$('#lc-bars'); if (el) el.value = this.bars; - this._refreshPianoRollRange(); - } - - _toggleOutput() { - // Flush any held preview notes on the previous target so we don't leave - // stuck voices on the synth or hanging note-ons on the MIDI device. - const previouslyLive = this._outputTarget === 'live'; - if (previouslyLive && this.outputDeviceId) { - for (const n of this._activeKeys) { - this.api.sendCommand('midi_send_note', { - deviceId: this.outputDeviceId, channel: this.outputChannel ?? 0, - note: n, velocity: 0 - }).catch(err => LoopUtils.handleError(err, 'editor.live.toggle.noteOff')); - } - } - this._previewStopAll(); - this._outputTarget = previouslyLive ? 'synth' : 'live'; - // When switching to MIDI mode, ensure outputMode reflects 'device' - // so _previewNoteOn / _previewViaDevice route properly to whatever - // device the embedded keyboard / session has set. - this.outputMode = (this._outputTarget === 'live') ? 'device' : 'synth'; - this._refreshHeaderOutputUI(); - this._setStatus(this._outputTarget === 'live' - ? this.t('loopCreator.outputLive') : this.t('loopCreator.outputSynth')); - } - - _startPlayheadAnimation() { - if (this._playheadRAF) return; - this._playheadStartTime = performance.now(); - const totalTicks = this.ppq * this.timeSigNum * this.bars; - const animate = () => { - if (!this.isPlaying) { this._playheadRAF = null; return; } - const elapsed = (performance.now() - this._playheadStartTime) / 1000; - const tick = Math.min(Math.round(elapsed * (this.tempo / 60) * this.ppq), totalTicks); - this.pianoRollEditor?.setCursor(tick); - this._playheadRAF = requestAnimationFrame(animate); - }; - this._playheadRAF = requestAnimationFrame(animate); - } - - _stopPlayheadAnimation() { - if (this._playheadRAF) { cancelAnimationFrame(this._playheadRAF); this._playheadRAF = null; } - this.pianoRollEditor?.setCursor(0); - } - - _startRecordingAnimation() { - const totalTicks = this.ppq * this.timeSigNum * this.bars; - const frame = () => { - if (!this.isRecording) { this._recordingRAF = null; return; } - const recTick = Math.round( - (performance.now() - this.recordStartTime) / 1000 * (this.tempo / 60) * this.ppq - ); - // Auto-stop when the playhead reaches the configured loop length - // (bars × time-sig × ppq). Pin the playhead to the end so the - // visual stays at the boundary instead of overshooting. - if (recTick >= totalTicks) { - this.pianoRollEditor?.setRecordingPlayhead(totalTicks); - this._recordingRAF = null; - this._stopRecording(); - return; - } - this.pianoRollEditor?.setRecordingPlayhead(recTick); - this._recordingRAF = requestAnimationFrame(frame); - }; - this._recordingRAF = requestAnimationFrame(frame); - } - - _stopRecordingAnimation() { - if (this._recordingRAF) { cancelAnimationFrame(this._recordingRAF); this._recordingRAF = null; } - this.pianoRollEditor?.setRecordingPlayhead(null); - } - - // ========================================================= - // NOTE RECORDING - // ========================================================= - - _playNote(note, velocity = 80) { - if (this._activeKeys.has(note)) return; - this._activeKeys.add(note); - if (this.isRecording) { - const elapsed = (performance.now() - this.recordStartTime) / 1000; - const tick = Math.round(elapsed * (this.tempo / 60) * this.ppq); - this.recordedNotes.push({ note, velocity, tick, startMs: performance.now() }); - } - this._previewNoteOn(note, velocity); - } - - _stopNote(note) { - this._activeKeys.delete(note); - if (this.isRecording) this._finalizeNoteOff(note); - this._previewNoteOff(note); - } - - // ── Live preview: route note on/off to the active output target ── - _previewNoteOn(note, velocity) { - // Route to a connected MIDI device when output is set to "live" - if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { - this.api.sendCommand('midi_send_note', { - deviceId: this.outputDeviceId, - channel: this.outputChannel ?? 0, - note, velocity - }).catch(err => LoopUtils.handleError(err, 'editor.live.noteOn')); - return; - } - // Otherwise play through the local synth so the user hears their input - if (!this._synth) return; - // Long duration acts as "until cancelled"; we cancel on note-off. - // Channel 9 pour les kits de batterie (convention GM) — sinon - // le synth interprète le programme comme un mélodique et joue - // un piano par défaut. - const ch = this._isDrumKit ? 9 : 0; - // Drum mode: defer the playNote until any in-flight loadDrumKit() - // resolves. Otherwise the first key press fires before the presets - // land and `playNote` returns null (silent first hit). - if (ch === 9 && typeof this._synth.ensureDrumKitReady === 'function' - && this._synth._drumKitLoading) { - this._synth.ensureDrumKitReady().then(() => { - try { - const envelopes = this._synth.playNote(note, velocity, 9, 9999); - if (envelopes) this._liveEnvelopes.set(note, envelopes); - } catch (err) { - LoopUtils.handleError(err, 'editor.live.synth.playNote'); - } - }); - return; - } + break; + case 'save-loop': + this._saveLoop(); + break; + case 'save-loop-as-new': + this._saveLoop({ asNew: true }); + break; + case 'toggle-metronome': + this._toggleMetronome(); + break; + case 'toggle-count-in': + this._toggleCountIn(); + break; + case 'close': + this.close(); + break; + } + } + + _switchTab(tab) { + if (tab !== 'piano' && tab !== 'editor') return; + if (this.activeTab === tab) return; + this.activeTab = tab; + this.$$('.le-tab').forEach((b) => { + const on = b.dataset.leTab === tab; + b.classList.toggle('le-tab--active', on); + b.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + const piano = this.$('#le-pane-piano'); + const editor = this.$('#le-pane-editor'); + if (piano) piano.classList.toggle('le-pane--hidden', tab !== 'piano'); + if (editor) editor.classList.toggle('le-pane--hidden', tab !== 'editor'); + if (tab === 'editor' && this.pianoRollEditor) { + requestAnimationFrame(() => this.pianoRollEditor.refit()); + } + if (tab === 'piano') { + // Re-probe keyboards each time the user comes back to the Piano + // tab, in case they plugged in a keyboard since the modal opened. + this._loadMidiInDevices(); + } + } + + _onChange(e) { + const id = e.target.id; + if (id === 'lc-timesig') { + const [num, den] = e.target.value.split(':').map(Number); + this.timeSigNum = num; + this.timeSigDen = den; + this._refreshPianoRollRange(); + } else if (id === 'lc-midi-in-device') { + this._midiInDevice = e.target.value || null; + } + } + + /** + * Enable / disable the REC button depending on whether the user has + * picked an instrument. Disabled record button gets a hint title + + * `aria-disabled` so screen readers don't fire it either. + */ + _refreshRecButtonEnabled() { + const btn = this.$('#lc-record-btn'); + if (!btn) return; + const ok = this._instrumentSelected === true; + btn.disabled = !ok; + btn.setAttribute('aria-disabled', ok ? 'false' : 'true'); + btn.title = ok + ? this.t('loopCreator.record') + : this.t('loopEditor.chooseInstrumentFirst') || + "Choisissez un instrument avant d'enregistrer"; + btn.classList.toggle('le-rec-big--disabled', !ok); + } + + _onInput(e) { + const id = e.target.id; + if (id === 'lc-tempo') { + const v = LoopUtils.validate.tempo(e.target.value, this.tempo); + if (v !== this.tempo) { + this.tempo = v; + this._scheduleRefreshRange(); + } + } else if (id === 'lc-bars') { + const v = LoopUtils.validate.editorBars(e.target.value, this.bars); + if (v !== this.bars) { + this.bars = v; + this._scheduleRefreshRange(); + } + } else if (id === 'lc-name-input') { + this.loopName = e.target.value; + } + } + + _scheduleRefreshRange() { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => { + this._refreshTimer = null; + this._refreshPianoRollRange(); + }, 200); + } + + // ========================================================= + // KEYBOARD SHORTCUTS + // ========================================================= + + _attachKeyboardShortcuts() { + if (this._keyHandler) return; + this._keyHandler = (e) => { + // Don't intercept while typing in a text/number input + const t = e.target; + const tag = (t?.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || t?.isContentEditable) return; + if (!this.isOpen) return; + + const mod = e.ctrlKey || e.metaKey; + // Only act if the editor modal is the front-most loop modal + const front = document.querySelector('.modal-overlay:not(.hidden) .loop-editor-modal'); + if (!front || !front.contains(this.dialog || front)) { + // Defensive: if our dialog isn't visible, skip + if (!this.dialog || !this.dialog.offsetParent) return; + } + + const ed = this.pianoRollEditor; + if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) { + e.preventDefault(); + ed?.undo?.(); + return; + } + if (mod && e.key.toLowerCase() === 'z' && e.shiftKey) { + e.preventDefault(); + ed?.redo?.(); + return; + } + if (mod && e.key.toLowerCase() === 'y') { + e.preventDefault(); + ed?.redo?.(); + return; + } + if (mod && e.key.toLowerCase() === 'a') { + e.preventDefault(); + ed?.selectAll?.(); + return; + } + if (mod && e.key.toLowerCase() === 'c') { + e.preventDefault(); + ed?.copy?.(); + return; + } + if (mod && e.key.toLowerCase() === 'v') { + e.preventDefault(); + ed?.paste?.(); + return; + } + if (mod && e.key.toLowerCase() === 's') { + e.preventDefault(); + this._saveLoop(); + return; + } + if (e.key === 'Delete' || e.key === 'Backspace') { + e.preventDefault(); + ed?.deleteSelected?.(); + return; + } + if (e.key === ' ') { + e.preventDefault(); + this.isPlaying ? this._stopAll() : this._previewLoop(); + return; + } + if (e.key === 'Escape') { + this._stopAll(); + return; + } + if (e.key.toLowerCase() === 'r') { + this._toggleRecording(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + this._toggleRecording(); + return; + } + // Mode shortcuts (no modifier): V/S/D + if (e.key.toLowerCase() === 'v' && !mod) { + ed?.setMode?.('view'); + return; + } + if (e.key.toLowerCase() === 's' && !mod && !e.shiftKey) { + ed?.setMode?.('select'); + return; + } + if (e.key.toLowerCase() === 'd' && !mod) { + ed?.setMode?.('dragpoly'); + return; + } + }; + document.addEventListener('keydown', this._keyHandler); + } + + _detachKeyboardShortcuts() { + if (this._keyHandler) { + document.removeEventListener('keydown', this._keyHandler); + this._keyHandler = null; + } + } + + // ========================================================= + // PIANO ROLL — thin proxies to PianoRollEditor + // ========================================================= + + _refreshPianoRollRange() { + this.pianoRollEditor?.setRange({ + tempo: this.tempo, + bars: this.bars, + ppq: this.ppq, + timeSigNum: this.timeSigNum, + timeSigDen: this.timeSigDen, + noteMin: this.outputNoteMin, + noteMax: this.outputNoteMax + }); + } + + _adjustTempo(d) { + this.tempo = LoopUtils.validate.tempo(this.tempo + d, this.tempo); + const el = this.$('#lc-tempo'); + if (el) el.value = this.tempo; + this._refreshPianoRollRange(); + } + + _adjustBars(d) { + this.bars = LoopUtils.validate.editorBars(this.bars + d, this.bars); + const el = this.$('#lc-bars'); + if (el) el.value = this.bars; + this._refreshPianoRollRange(); + } + + _toggleOutput() { + // Flush any held preview notes on the previous target so we don't leave + // stuck voices on the synth or hanging note-ons on the MIDI device. + const previouslyLive = this._outputTarget === 'live'; + if (previouslyLive && this.outputDeviceId) { + for (const n of this._activeKeys) { + this.api + .sendCommand('midi_send_note', { + deviceId: this.outputDeviceId, + channel: this.outputChannel ?? 0, + note: n, + velocity: 0 + }) + .catch((err) => LoopUtils.handleError(err, 'editor.live.toggle.noteOff')); + } + } + this._previewStopAll(); + this._outputTarget = previouslyLive ? 'synth' : 'live'; + // When switching to MIDI mode, ensure outputMode reflects 'device' + // so _previewNoteOn / _previewViaDevice route properly to whatever + // device the embedded keyboard / session has set. + this.outputMode = this._outputTarget === 'live' ? 'device' : 'synth'; + this._refreshHeaderOutputUI(); + this._setStatus( + this._outputTarget === 'live' + ? this.t('loopCreator.outputLive') + : this.t('loopCreator.outputSynth') + ); + } + + _startPlayheadAnimation() { + if (this._playheadRAF) return; + this._playheadStartTime = performance.now(); + const totalTicks = this.ppq * this.timeSigNum * this.bars; + const animate = () => { + if (!this.isPlaying) { + this._playheadRAF = null; + return; + } + const elapsed = (performance.now() - this._playheadStartTime) / 1000; + const tick = Math.min(Math.round(elapsed * (this.tempo / 60) * this.ppq), totalTicks); + this.pianoRollEditor?.setCursor(tick); + this._playheadRAF = requestAnimationFrame(animate); + }; + this._playheadRAF = requestAnimationFrame(animate); + } + + _stopPlayheadAnimation() { + if (this._playheadRAF) { + cancelAnimationFrame(this._playheadRAF); + this._playheadRAF = null; + } + this.pianoRollEditor?.setCursor(0); + } + + _startRecordingAnimation() { + const totalTicks = this.ppq * this.timeSigNum * this.bars; + const frame = () => { + if (!this.isRecording) { + this._recordingRAF = null; + return; + } + const recTick = Math.round( + ((performance.now() - this.recordStartTime) / 1000) * (this.tempo / 60) * this.ppq + ); + // Auto-stop when the playhead reaches the configured loop length + // (bars × time-sig × ppq). Pin the playhead to the end so the + // visual stays at the boundary instead of overshooting. + if (recTick >= totalTicks) { + this.pianoRollEditor?.setRecordingPlayhead(totalTicks); + this._recordingRAF = null; + this._stopRecording(); + return; + } + this.pianoRollEditor?.setRecordingPlayhead(recTick); + this._recordingRAF = requestAnimationFrame(frame); + }; + this._recordingRAF = requestAnimationFrame(frame); + } + + _stopRecordingAnimation() { + if (this._recordingRAF) { + cancelAnimationFrame(this._recordingRAF); + this._recordingRAF = null; + } + this.pianoRollEditor?.setRecordingPlayhead(null); + } + + // ========================================================= + // NOTE RECORDING + // ========================================================= + + _playNote(note, velocity = 80) { + if (this._activeKeys.has(note)) return; + this._activeKeys.add(note); + if (this.isRecording) { + const elapsed = (performance.now() - this.recordStartTime) / 1000; + const tick = Math.round(elapsed * (this.tempo / 60) * this.ppq); + this.recordedNotes.push({ note, velocity, tick, startMs: performance.now() }); + } + this._previewNoteOn(note, velocity); + } + + _stopNote(note) { + this._activeKeys.delete(note); + if (this.isRecording) this._finalizeNoteOff(note); + this._previewNoteOff(note); + } + + // ── Live preview: route note on/off to the active output target ── + _previewNoteOn(note, velocity) { + // Route to a connected MIDI device when output is set to "live" + if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { + this.api + .sendCommand('midi_send_note', { + deviceId: this.outputDeviceId, + channel: this.outputChannel ?? 0, + note, + velocity + }) + .catch((err) => LoopUtils.handleError(err, 'editor.live.noteOn')); + return; + } + // Otherwise play through the local synth so the user hears their input + if (!this._synth) return; + // Long duration acts as "until cancelled"; we cancel on note-off. + // Channel 9 pour les kits de batterie (convention GM) — sinon + // le synth interprète le programme comme un mélodique et joue + // un piano par défaut. + const ch = this._isDrumKit ? 9 : 0; + // Drum mode: defer the playNote until any in-flight loadDrumKit() + // resolves. Otherwise the first key press fires before the presets + // land and `playNote` returns null (silent first hit). + if ( + ch === 9 && + typeof this._synth.ensureDrumKitReady === 'function' && + this._synth._drumKitLoading + ) { + this._synth.ensureDrumKitReady().then(() => { try { - const envelopes = this._synth.playNote(note, velocity, ch, 9999); - if (envelopes) this._liveEnvelopes.set(note, envelopes); + const envelopes = this._synth.playNote(note, velocity, 9, 9999); + if (envelopes) this._liveEnvelopes.set(note, envelopes); } catch (err) { - LoopUtils.handleError(err, 'editor.live.synth.playNote'); + LoopUtils.handleError(err, 'editor.live.synth.playNote'); } - } - - _previewNoteOff(note) { - if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { - this.api.sendCommand('midi_send_note', { - deviceId: this.outputDeviceId, - channel: this.outputChannel ?? 0, - note, velocity: 0 - }).catch(err => LoopUtils.handleError(err, 'editor.live.noteOff')); - return; - } - const envelopes = this._liveEnvelopes.get(note); - if (!envelopes) return; + }); + return; + } + try { + const envelopes = this._synth.playNote(note, velocity, ch, 9999); + if (envelopes) this._liveEnvelopes.set(note, envelopes); + } catch (err) { + LoopUtils.handleError(err, 'editor.live.synth.playNote'); + } + } + + _previewNoteOff(note) { + if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { + this.api + .sendCommand('midi_send_note', { + deviceId: this.outputDeviceId, + channel: this.outputChannel ?? 0, + note, + velocity: 0 + }) + .catch((err) => LoopUtils.handleError(err, 'editor.live.noteOff')); + return; + } + const envelopes = this._liveEnvelopes.get(note); + if (!envelopes) return; + for (const env of envelopes) { + try { + env?.cancel?.(); + } catch (err) { + LoopUtils.handleError(err, 'editor.live.synth.cancel'); + } + } + this._liveEnvelopes.delete(note); + } + + _previewStopAll() { + if (this._liveEnvelopes.size) { + for (const envelopes of this._liveEnvelopes.values()) { for (const env of envelopes) { - try { env?.cancel?.(); } - catch (err) { LoopUtils.handleError(err, 'editor.live.synth.cancel'); } - } - this._liveEnvelopes.delete(note); - } - - _previewStopAll() { - if (this._liveEnvelopes.size) { - for (const envelopes of this._liveEnvelopes.values()) { - for (const env of envelopes) { - try { env?.cancel?.(); } catch (_) { /* best-effort cleanup */ } - } - } - this._liveEnvelopes.clear(); - } - } - - _finalizeNoteOff(note) { - const idx = this.recordedNotes.findIndex(r => r.note === note); - if (idx === -1) return; - const rec = this.recordedNotes.splice(idx, 1)[0]; - const durMs = performance.now() - rec.startMs; - const durTicks = Math.max(30, Math.round(durMs / 1000 * (this.tempo / 60) * this.ppq)); - // Read quantize value from the PianoRollEditor's Grid group (if mounted) - const qEl = this.pianoRollEditor?.host?.querySelector('[data-pre-field="pre-quantize"]'); - const q = parseInt(qEl?.value ?? 0); - const t = q > 0 ? Math.round(rec.tick / q) * q : rec.tick; - const g = q > 0 ? Math.max(q, Math.round(durTicks / q) * q) : durTicks; - this._addNoteToRoll({ t, n: note, v: rec.velocity, g }); - } - - _addNoteToRoll(noteObj) { - this.pianoRollEditor?.addNote(noteObj); - } - - // ========================================================= - // RECORDING + MIDI IN - // ========================================================= - - _toggleMetronome() { - this._metronomeEnabled = !this._metronomeEnabled; - const b = this.$('#lc-metronome-btn'); - if (b) b.setAttribute('aria-pressed', this._metronomeEnabled ? 'true' : 'false'); - if (!this._metronomeEnabled) this._stopMetronome(); - else if (this.isRecording || this.isPlaying) this._startMetronome(); - } - - _toggleCountIn() { - this._countInEnabled = !this._countInEnabled; - const b = this.$('#lc-countin-btn'); - if (b) b.setAttribute('aria-pressed', this._countInEnabled ? 'true' : 'false'); - } - - _ensureMetronomeCtx() { - if (!this._metronomeCtx) { - const Ctx = window.AudioContext || window.webkitAudioContext; - if (Ctx) this._metronomeCtx = new Ctx(); + try { + env?.cancel?.(); + } catch (_) { + /* best-effort cleanup */ + } } - return this._metronomeCtx; - } - - _tick(strong = false) { - const ctx = this._ensureMetronomeCtx(); - if (!ctx) return; - const now = ctx.currentTime; - const osc = ctx.createOscillator(); - const gain = ctx.createGain(); - osc.type = 'square'; - osc.frequency.value = strong ? 1500 : 900; - gain.gain.setValueAtTime(0, now); - gain.gain.linearRampToValueAtTime(strong ? 0.25 : 0.15, now + 0.005); - gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.07); - osc.connect(gain).connect(ctx.destination); - osc.start(now); - osc.stop(now + 0.08); - } - - _startMetronome() { - this._stopMetronome(); - if (!this._metronomeEnabled) return; - const secPerBeat = 60 / this.tempo; - const beatsPerBar = this.timeSigNum; - let beat = 0; - const fire = () => { - this._tick(beat % beatsPerBar === 0); - beat++; - }; - fire(); - const id = setInterval(fire, secPerBeat * 1000); - this._metronomeTimers.push(id); - } - - _stopMetronome() { - this._metronomeTimers.forEach(id => clearInterval(id)); - this._metronomeTimers = []; - } - - _toggleRecording() { - this.isRecording ? this._stopRecording() : this._startRecording(); - } - - _startRecording() { - // Refuse to record until the user has picked an instrument — - // otherwise notes go silently to the default piano (channel 0, - // program 0) and the loop saves with the wrong sound. The - // user-facing hint nudges them toward the header dropdown. - if (!this._instrumentSelected) { - this._setStatus(this.t('loopEditor.chooseInstrumentFirst') || 'Choisissez un instrument avant d\'enregistrer'); - LoopUtils.toast(this.t('loopEditor.chooseInstrumentFirst') || 'Choisissez un instrument avant d\'enregistrer', 'warning'); - this.$('#le-instrument-select')?.focus(); - return; - } - if (this._countInEnabled) { - this._runCountInThen(() => this._beginRecording()); - } else { - this._beginRecording(); + } + this._liveEnvelopes.clear(); + } + } + + _finalizeNoteOff(note) { + const idx = this.recordedNotes.findIndex((r) => r.note === note); + if (idx === -1) return; + const rec = this.recordedNotes.splice(idx, 1)[0]; + const durMs = performance.now() - rec.startMs; + const durTicks = Math.max(30, Math.round((durMs / 1000) * (this.tempo / 60) * this.ppq)); + // Read quantize value from the PianoRollEditor's Grid group (if mounted) + const qEl = this.pianoRollEditor?.host?.querySelector('[data-pre-field="pre-quantize"]'); + const q = parseInt(qEl?.value ?? 0); + const t = q > 0 ? Math.round(rec.tick / q) * q : rec.tick; + const g = q > 0 ? Math.max(q, Math.round(durTicks / q) * q) : durTicks; + this._addNoteToRoll({ t, n: note, v: rec.velocity, g }); + } + + _addNoteToRoll(noteObj) { + this.pianoRollEditor?.addNote(noteObj); + } + + // ========================================================= + // RECORDING + MIDI IN + // ========================================================= + + _toggleMetronome() { + this._metronomeEnabled = !this._metronomeEnabled; + const b = this.$('#lc-metronome-btn'); + if (b) b.setAttribute('aria-pressed', this._metronomeEnabled ? 'true' : 'false'); + if (!this._metronomeEnabled) this._stopMetronome(); + else if (this.isRecording || this.isPlaying) this._startMetronome(); + } + + _toggleCountIn() { + this._countInEnabled = !this._countInEnabled; + const b = this.$('#lc-countin-btn'); + if (b) b.setAttribute('aria-pressed', this._countInEnabled ? 'true' : 'false'); + } + + _ensureMetronomeCtx() { + if (!this._metronomeCtx) { + const Ctx = window.AudioContext || window.webkitAudioContext; + if (Ctx) this._metronomeCtx = new Ctx(); + } + return this._metronomeCtx; + } + + _tick(strong = false) { + const ctx = this._ensureMetronomeCtx(); + if (!ctx) return; + const now = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = 'square'; + osc.frequency.value = strong ? 1500 : 900; + gain.gain.setValueAtTime(0, now); + gain.gain.linearRampToValueAtTime(strong ? 0.25 : 0.15, now + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.07); + osc.connect(gain).connect(ctx.destination); + osc.start(now); + osc.stop(now + 0.08); + } + + _startMetronome() { + this._stopMetronome(); + if (!this._metronomeEnabled) return; + const secPerBeat = 60 / this.tempo; + const beatsPerBar = this.timeSigNum; + let beat = 0; + const fire = () => { + this._tick(beat % beatsPerBar === 0); + beat++; + }; + fire(); + const id = setInterval(fire, secPerBeat * 1000); + this._metronomeTimers.push(id); + } + + _stopMetronome() { + this._metronomeTimers.forEach((id) => clearInterval(id)); + this._metronomeTimers = []; + } + + _toggleRecording() { + this.isRecording ? this._stopRecording() : this._startRecording(); + } + + _startRecording() { + // Refuse to record until the user has picked an instrument — + // otherwise notes go silently to the default piano (channel 0, + // program 0) and the loop saves with the wrong sound. The + // user-facing hint nudges them toward the header dropdown. + if (!this._instrumentSelected) { + this._setStatus( + this.t('loopEditor.chooseInstrumentFirst') || "Choisissez un instrument avant d'enregistrer" + ); + LoopUtils.toast( + this.t('loopEditor.chooseInstrumentFirst') || + "Choisissez un instrument avant d'enregistrer", + 'warning' + ); + this.$('#le-instrument-select')?.focus(); + return; + } + if (this._countInEnabled) { + this._runCountInThen(() => this._beginRecording()); + } else { + this._beginRecording(); + } + } + + _beginRecording() { + this.isRecording = true; + this.recordedNotes = []; + this.recordStartTime = performance.now(); + this.$('#lc-record-btn')?.classList.add('lc-btn-record--active'); + this.$('#lc-rec-indicator')?.classList.remove('hidden'); + if (this._midiInDevice) this._startMidiInMonitor(); + this._startRecordingAnimation(); + this._startRecordingTimer(); + if (this._metronomeEnabled) this._startMetronome(); + this._setStatus(this.t('loopCreator.statusRecording')); + } + + _runCountInThen(then) { + // Planning absolu sur l'horloge système (performance.now) plutôt + // que des setTimeout cumulatifs, qui dérivent de 100+ ms sur 4 + // mesures à cause du jitter JS (AUDIT §L5). On planifie chaque + // beat à `t0 + i × secPerBeat` ; si le timer tire en retard, le + // suivant est ré-aligné sur l'horloge absolue. + this._countInActive = true; + const beatsPerBar = this.timeSigNum; + const secPerBeat = 60 / this.tempo; + const btn = this.$('#lc-record-btn'); + btn?.classList.add('lc-btn-record--active'); + const t0 = performance.now(); + const scheduleBeat = (beat) => { + if (!this._countInActive) return; + const targetMs = t0 + beat * secPerBeat * 1000; + const delay = Math.max(0, targetMs - performance.now()); + setTimeout(() => { + if (!this._countInActive) return; + this._tick(beat === 0); + if (beat + 1 >= beatsPerBar) { + this._countInActive = false; + then(); + return; } - } - - _beginRecording() { - this.isRecording = true; - this.recordedNotes = []; - this.recordStartTime = performance.now(); - this.$('#lc-record-btn')?.classList.add('lc-btn-record--active'); - this.$('#lc-rec-indicator')?.classList.remove('hidden'); - if (this._midiInDevice) this._startMidiInMonitor(); - this._startRecordingAnimation(); - this._startRecordingTimer(); - if (this._metronomeEnabled) this._startMetronome(); - this._setStatus(this.t('loopCreator.statusRecording')); - } - - _runCountInThen(then) { - // Planning absolu sur l'horloge système (performance.now) plutôt - // que des setTimeout cumulatifs, qui dérivent de 100+ ms sur 4 - // mesures à cause du jitter JS (AUDIT §L5). On planifie chaque - // beat à `t0 + i × secPerBeat` ; si le timer tire en retard, le - // suivant est ré-aligné sur l'horloge absolue. - this._countInActive = true; - const beatsPerBar = this.timeSigNum; - const secPerBeat = 60 / this.tempo; - const btn = this.$('#lc-record-btn'); - btn?.classList.add('lc-btn-record--active'); - const t0 = performance.now(); - const scheduleBeat = (beat) => { - if (!this._countInActive) return; - const targetMs = t0 + beat * secPerBeat * 1000; - const delay = Math.max(0, targetMs - performance.now()); - setTimeout(() => { - if (!this._countInActive) return; - this._tick(beat === 0); - if (beat + 1 >= beatsPerBar) { - this._countInActive = false; - then(); - return; - } - this._setStatus(this.t('loopEditor.countInStatus', { beat: beat + 2, total: beatsPerBar })); - scheduleBeat(beat + 1); - }, delay); - }; - this._setStatus(this.t('loopEditor.countInStatus', { beat: 1, total: beatsPerBar })); - scheduleBeat(0); - } - - _stopRecording() { - if (this._countInActive) { - this._countInActive = false; - this.$('#lc-record-btn')?.classList.remove('lc-btn-record--active'); - this._setStatus(''); - return; - } - this.isRecording = false; - for (const rec of [...this.recordedNotes]) this._finalizeNoteOff(rec.note); - this.recordedNotes = []; - this.$('#lc-record-btn')?.classList.remove('lc-btn-record--active'); - this.$('#lc-rec-indicator')?.classList.add('hidden'); - this._stopMidiInMonitor(); - this._stopRecordingAnimation(); - this._stopRecordingTimer(); - this._stopMetronome(); - this._setStatus(this.t('loopCreator.statusRecordingDone')); - } - - _startRecordingTimer() { - this._stopRecordingTimer(); - const el = this.$('#lc-rec-time'); - if (!el) return; - const update = () => { - const elapsed = Math.floor((performance.now() - this.recordStartTime) / 1000); - const m = Math.floor(elapsed / 60); - const s = elapsed % 60; - el.textContent = `${m}:${s.toString().padStart(2, '0')}`; - }; - update(); - this._recTimerId = setInterval(update, 500); - } - - _stopRecordingTimer() { - if (this._recTimerId) { clearInterval(this._recTimerId); this._recTimerId = null; } - } - - async _startMidiInMonitor() { - if (!this._midiInDevice || this._monitorActive) return; - // Token de session : invalidé par _stopMidiInMonitor / onClose. Si - // la modale est fermée pendant le `await monitor_start`, on ne doit - // PAS attacher le handler ni marquer active (AUDIT §L2). - const token = Symbol('midiInSession'); - this._monitorSession = token; + this._setStatus(this.t('loopEditor.countInStatus', { beat: beat + 2, total: beatsPerBar })); + scheduleBeat(beat + 1); + }, delay); + }; + this._setStatus(this.t('loopEditor.countInStatus', { beat: 1, total: beatsPerBar })); + scheduleBeat(0); + } + + _stopRecording() { + if (this._countInActive) { + this._countInActive = false; + this.$('#lc-record-btn')?.classList.remove('lc-btn-record--active'); + this._setStatus(''); + return; + } + this.isRecording = false; + for (const rec of [...this.recordedNotes]) this._finalizeNoteOff(rec.note); + this.recordedNotes = []; + this.$('#lc-record-btn')?.classList.remove('lc-btn-record--active'); + this.$('#lc-rec-indicator')?.classList.add('hidden'); + this._stopMidiInMonitor(); + this._stopRecordingAnimation(); + this._stopRecordingTimer(); + this._stopMetronome(); + this._setStatus(this.t('loopCreator.statusRecordingDone')); + } + + _startRecordingTimer() { + this._stopRecordingTimer(); + const el = this.$('#lc-rec-time'); + if (!el) return; + const update = () => { + const elapsed = Math.floor((performance.now() - this.recordStartTime) / 1000); + const m = Math.floor(elapsed / 60); + const s = elapsed % 60; + el.textContent = `${m}:${s.toString().padStart(2, '0')}`; + }; + update(); + this._recTimerId = setInterval(update, 500); + } + + _stopRecordingTimer() { + if (this._recTimerId) { + clearInterval(this._recTimerId); + this._recTimerId = null; + } + } + + async _startMidiInMonitor() { + if (!this._midiInDevice || this._monitorActive) return; + // Token de session : invalidé par _stopMidiInMonitor / onClose. Si + // la modale est fermée pendant le `await monitor_start`, on ne doit + // PAS attacher le handler ni marquer active (AUDIT §L2). + const token = Symbol('midiInSession'); + this._monitorSession = token; + try { + await this.api.sendCommand('monitor_start', { deviceId: this._midiInDevice }); + if (this._monitorSession !== token) { + // Session annulée pendant l'await — stop ce qu'on vient de démarrer. try { - await this.api.sendCommand('monitor_start', { deviceId: this._midiInDevice }); - if (this._monitorSession !== token) { - // Session annulée pendant l'await — stop ce qu'on vient de démarrer. - try { await this.api.sendCommand('monitor_stop', { deviceId: this._midiInDevice }); } catch (_) {} - return; - } - this._monitorActive = true; - this._midiInHandler = (data) => { - // Garde de session : ignore les events orphelins si la - // modale a été close ou un autre device sélectionné entre-temps. - if (this._monitorSession !== token) return; - if (data.device !== this._midiInDevice) return; - if (!this.isRecording) return; - const type = (data.type || '').toLowerCase(); - const note = data.data?.note ?? data.data?.n; - const vel = data.data?.velocity ?? data.data?.v ?? 64; - if (note == null) return; - if (type === 'noteon' && vel > 0) this._playNote(note, vel); - else if (type === 'noteoff' || (type === 'noteon' && vel === 0)) this._stopNote(note); - }; - this.api.on('monitor_event', this._midiInHandler); - } catch (err) { - this._monitorSession = null; - LoopUtils.handleError(err, 'editor.midiIn.start', { - toast: this.t('loopEditor.errMidiIn') - }); - } - } + await this.api.sendCommand('monitor_stop', { deviceId: this._midiInDevice }); + } catch (_) {} + return; + } + this._monitorActive = true; + this._midiInHandler = (data) => { + // Garde de session : ignore les events orphelins si la + // modale a été close ou un autre device sélectionné entre-temps. + if (this._monitorSession !== token) return; + if (data.device !== this._midiInDevice) return; + if (!this.isRecording) return; + const type = (data.type || '').toLowerCase(); + const note = data.data?.note ?? data.data?.n; + const vel = data.data?.velocity ?? data.data?.v ?? 64; + if (note == null) return; + if (type === 'noteon' && vel > 0) this._playNote(note, vel); + else if (type === 'noteoff' || (type === 'noteon' && vel === 0)) this._stopNote(note); + }; + this.api.on('monitor_event', this._midiInHandler); + } catch (err) { + this._monitorSession = null; + LoopUtils.handleError(err, 'editor.midiIn.start', { + toast: this.t('loopEditor.errMidiIn') + }); + } + } + + async _stopMidiInMonitor() { + // Invalide la session AVANT tout await — couvre le cas où on a + // démarré mais pas encore attaché le handler. + this._monitorSession = null; + const wasActive = this._monitorActive; + this._monitorActive = false; + // Détache toujours le handler s'il a été enregistré, même si la + // commande monitor_stop échoue derrière. + if (this._midiInHandler) { + this.api.off?.('monitor_event', this._midiInHandler); + this._midiInHandler = null; + } + if (wasActive && this._midiInDevice) { + try { + await this.api.sendCommand('monitor_stop', { deviceId: this._midiInDevice }); + } catch (err) { + LoopUtils.handleError(err, 'editor.midiIn.stop'); + } + } + } + + /** + * Populate the MIDI-In selector with **real keyboards only** — i.e. + * devices that have replied to a Universal SysEx Identity Request with + * a manufacturer id that is NOT our own DIY GMB code (0x7D). + * + * The piano control bar (`#le-ctrl-bar-piano`) is shown only when at + * least one such device is detected ; otherwise it stays hidden. + */ + async _loadMidiInDevices() { + const sel = this.$('#lc-midi-in-device'); + const bar = this.$('#le-ctrl-bar-piano'); + if (!sel) return; + + // Probe every connected device first so freshly-plugged keyboards + // get a chance to identify themselves before we filter. + await this._probeKeyboardIdentities(); + + try { + const allDevices = await this.api.listDevices(); + const realKeyboards = (allDevices || []).filter((d) => { + const connected = d.status === 2 || d.connected === true; + if (!connected) return false; + const mfr = d.sysex_manufacturer_id; + if (!mfr) return false; // never identified → not a keyboard + const mfrLow = String(mfr).toLowerCase(); + if (mfrLow === '0x7d' || mfrLow === '7d') return false; // GMB DIY + return true; + }); + + const existing = sel.value; + sel.innerHTML = ``; + for (const d of realKeyboards) { + const id = d.device_id || d.id; + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = `IN: ${d.displayName || d.name || id}`; + if (id === existing) opt.selected = true; + sel.appendChild(opt); + } + + const hasKeyboard = realKeyboards.length > 0; + if (bar) bar.classList.toggle('le-ctrl-bar-piano--hidden', !hasKeyboard); + if (!hasKeyboard) { + this._midiInDevice = null; + } + } catch (err) { + LoopUtils.handleError(err, 'editor.midiIn.list'); + } + } + + /** + * Send a SysEx Identity Request to every connected device and wait + * briefly so replies have time to come back and be persisted by the + * backend. Best-effort — failures are ignored. + */ + async _probeKeyboardIdentities() { + try { + const all = await this.api.listDevices(); + const candidates = (all || []).filter((d) => d.status === 2 || d.connected === true); + const probes = candidates.map((d) => { + const id = d.device_id || d.id; + return this.api.sendCommand('sysex_identity_request', { deviceId: id }).catch(() => {}); // input-only / unsupported devices throw — ignore + }); + if (!probes.length) return; + await Promise.race([Promise.all(probes), new Promise((res) => setTimeout(res, 150))]); + // Give replies a window to land in the DB before re-querying. + await new Promise((res) => setTimeout(res, 350)); + } catch (err) { + LoopUtils.handleError(err, 'editor.midiIn.probe'); + } + } + + _mountKeyboardPanel() { + if (!window.keyboardModal) return; + const container = this.$('#lc-kb-panel'); + if (!container) return; + window.keyboardModal.mountAsPanel(container, { + onNoteOn: (note, vel) => this._playNote(note, vel), + onNoteOff: (note) => this._stopNote(note), + onInstrumentSelected: ({ + deviceId, + channel, + gmProgram, + instrumentType, + isDrum: isDrumFromKbd + }) => { + // Cancel held preview voices so they don't ring on with the + // previous instrument / device after the switch. + this._previewStopAll(); - async _stopMidiInMonitor() { - // Invalide la session AVANT tout await — couvre le cas où on a - // démarré mais pas encore attaché le handler. - this._monitorSession = null; - const wasActive = this._monitorActive; - this._monitorActive = false; - // Détache toujours le handler s'il a été enregistré, même si la - // commande monitor_stop échoue derrière. - if (this._midiInHandler) { - this.api.off?.('monitor_event', this._midiInHandler); - this._midiInHandler = null; + // Détection drum kit. KeyboardModal nous transmet + // maintenant `isDrum` (calculé depuis + // `caps.instrument_type` + channel === 9 + gmProgram ≥ 128 + // + viewMode === 'drumpad'). Fallback local le plus + // large possible pour les vieux callers qui n'envoient + // pas le flag — couvre les types qu'on a vu en DB : + // 'drum', 'drums', 'percussion', 'percussive'. + const drumLikeTypes = new Set([ + 'drum', + 'drums', + 'drumkit', + 'drum_kit', + 'percussion', + 'percussive' + ]); + const isDrum = + isDrumFromKbd === true || + (instrumentType && drumLikeTypes.has(String(instrumentType).toLowerCase())) || + channel === 9 || + (gmProgram != null && gmProgram >= 128); + + // Drum-kit offset convention : `MidiSynthesizer._decodeKitProgram` + // reads kit programs as `gmProgram + 128`. We preserve the + // user's actual kit choice (Standard / Jazz / SFX Kit …) + // by ADDING 128 rather than clamping to 128 — otherwise + // every drum loop saved as Standard Kit. + const rawProgram = gmProgram ?? 0; + const storedProgram = isDrum + ? rawProgram >= 128 + ? rawProgram + : rawProgram + 128 + : rawProgram; + + // `outputMode` n'est PAS réaffecté ici : il appartient à la + // bascule synth/live (`_toggleOutput`), qui le maintient + // aligné sur `_outputTarget`. Le choix de l'utilisateur + // persiste donc lorsqu'il change d'instrument. + this.outputDeviceId = deviceId || null; + this.outputChannel = isDrum ? 9 : (channel ?? 0); + this.outputGmProgram = storedProgram; + this.instrumentProgram = storedProgram; + this._isDrumKit = isDrum; + // A device-routed instrument also counts as an explicit + // choice — unlock REC and reflect it on the header + // dropdown so the two stay visually in sync. + this._instrumentSelected = true; + const sel = this.$('#le-instrument-select'); + if (sel) { + const v = String(isDrum ? 128 : (gmProgram ?? 0)); + if (sel.value !== v) sel.value = v; } - if (wasActive && this._midiInDevice) { - try { await this.api.sendCommand('monitor_stop', { deviceId: this._midiInDevice }); } - catch (err) { LoopUtils.handleError(err, 'editor.midiIn.stop'); } - } - } - - /** - * Populate the MIDI-In selector with **real keyboards only** — i.e. - * devices that have replied to a Universal SysEx Identity Request with - * a manufacturer id that is NOT our own DIY GMB code (0x7D). - * - * The piano control bar (`#le-ctrl-bar-piano`) is shown only when at - * least one such device is detected ; otherwise it stays hidden. - */ - async _loadMidiInDevices() { - const sel = this.$('#lc-midi-in-device'); - const bar = this.$('#le-ctrl-bar-piano'); - if (!sel) return; - - // Probe every connected device first so freshly-plugged keyboards - // get a chance to identify themselves before we filter. - await this._probeKeyboardIdentities(); + this._refreshRecButtonEnabled(); try { - const allDevices = await this.api.listDevices(); - const realKeyboards = (allDevices || []).filter(d => { - const connected = (d.status === 2 || d.connected === true); - if (!connected) return false; - const mfr = d.sysex_manufacturer_id; - if (!mfr) return false; // never identified → not a keyboard - const mfrLow = String(mfr).toLowerCase(); - if (mfrLow === '0x7d' || mfrLow === '7d') return false; // GMB DIY - return true; - }); - - const existing = sel.value; - sel.innerHTML = ``; - for (const d of realKeyboards) { - const id = d.device_id || d.id; - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = `IN: ${d.displayName || d.name || id}`; - if (id === existing) opt.selected = true; - sel.appendChild(opt); - } - - const hasKeyboard = realKeyboards.length > 0; - if (bar) bar.classList.toggle('le-ctrl-bar-piano--hidden', !hasKeyboard); - if (!hasKeyboard) { - this._midiInDevice = null; + if (isDrum) { + // Écrit le programme sur le canal 9 (canal drums GM) + // ET pré-charge le kit pour que la 1ère frappe sonne. + this._synth?.setChannelInstrument?.(9, this.instrumentProgram); + this._synth + ?.loadDrumKit?.() + .catch((err) => LoopUtils.handleError(err, 'editor.synth.loadDrumKit')); + } else { + this._synth?.setChannelInstrument?.(0, this.instrumentProgram); + // Préload pour que la 1ère key press produise du son. + if (this._synth && !this._synth.loadedInstruments?.has(this.instrumentProgram)) { + this._synth + .loadInstrument(this.instrumentProgram) + .catch((err) => LoopUtils.handleError(err, 'editor.synth.loadInstrument')); } + } } catch (err) { - LoopUtils.handleError(err, 'editor.midiIn.list'); + LoopUtils.handleError(err, 'editor.synth.setChannelInstrument'); } - } - - /** - * Send a SysEx Identity Request to every connected device and wait - * briefly so replies have time to come back and be persisted by the - * backend. Best-effort — failures are ignored. - */ - async _probeKeyboardIdentities() { - try { - const all = await this.api.listDevices(); - const candidates = (all || []).filter(d => d.status === 2 || d.connected === true); - const probes = candidates.map(d => { - const id = d.device_id || d.id; - return this.api.sendCommand('sysex_identity_request', { deviceId: id }) - .catch(() => {}); // input-only / unsupported devices throw — ignore - }); - if (!probes.length) return; - await Promise.race([ - Promise.all(probes), - new Promise(res => setTimeout(res, 150)) - ]); - // Give replies a window to land in the DB before re-querying. - await new Promise(res => setTimeout(res, 350)); - } catch (err) { - LoopUtils.handleError(err, 'editor.midiIn.probe'); - } - } - - _mountKeyboardPanel() { - if (!window.keyboardModal) return; - const container = this.$('#lc-kb-panel'); - if (!container) return; - window.keyboardModal.mountAsPanel(container, { - onNoteOn: (note, vel) => this._playNote(note, vel), - onNoteOff: (note) => this._stopNote(note), - onInstrumentSelected: ({ deviceId, channel, gmProgram, instrumentType, isDrum: isDrumFromKbd }) => { - // Cancel held preview voices so they don't ring on with the - // previous instrument / device after the switch. - this._previewStopAll(); - - // Détection drum kit. KeyboardModal nous transmet - // maintenant `isDrum` (calculé depuis - // `caps.instrument_type` + channel === 9 + gmProgram ≥ 128 - // + viewMode === 'drumpad'). Fallback local le plus - // large possible pour les vieux callers qui n'envoient - // pas le flag — couvre les types qu'on a vu en DB : - // 'drum', 'drums', 'percussion', 'percussive'. - const drumLikeTypes = new Set(['drum', 'drums', 'drumkit', 'drum_kit', 'percussion', 'percussive']); - const isDrum = isDrumFromKbd === true - || (instrumentType && drumLikeTypes.has(String(instrumentType).toLowerCase())) - || channel === 9 - || (gmProgram != null && gmProgram >= 128); - - // Drum-kit offset convention : `MidiSynthesizer._decodeKitProgram` - // reads kit programs as `gmProgram + 128`. We preserve the - // user's actual kit choice (Standard / Jazz / SFX Kit …) - // by ADDING 128 rather than clamping to 128 — otherwise - // every drum loop saved as Standard Kit. - const rawProgram = gmProgram ?? 0; - const storedProgram = isDrum - ? (rawProgram >= 128 ? rawProgram : rawProgram + 128) - : rawProgram; - - // `outputMode` n'est PAS réaffecté ici : il appartient à la - // bascule synth/live (`_toggleOutput`), qui le maintient - // aligné sur `_outputTarget`. Le choix de l'utilisateur - // persiste donc lorsqu'il change d'instrument. - this.outputDeviceId = deviceId || null; - this.outputChannel = isDrum ? 9 : (channel ?? 0); - this.outputGmProgram = storedProgram; - this.instrumentProgram = storedProgram; - this._isDrumKit = isDrum; - // A device-routed instrument also counts as an explicit - // choice — unlock REC and reflect it on the header - // dropdown so the two stay visually in sync. - this._instrumentSelected = true; - const sel = this.$('#le-instrument-select'); - if (sel) { - const v = String(isDrum ? 128 : (gmProgram ?? 0)); - if (sel.value !== v) sel.value = v; - } - this._refreshRecButtonEnabled(); - - try { - if (isDrum) { - // Écrit le programme sur le canal 9 (canal drums GM) - // ET pré-charge le kit pour que la 1ère frappe sonne. - this._synth?.setChannelInstrument?.(9, this.instrumentProgram); - this._synth?.loadDrumKit?.().catch(err => - LoopUtils.handleError(err, 'editor.synth.loadDrumKit')); - } else { - this._synth?.setChannelInstrument?.(0, this.instrumentProgram); - // Préload pour que la 1ère key press produise du son. - if (this._synth && !this._synth.loadedInstruments?.has(this.instrumentProgram)) { - this._synth.loadInstrument(this.instrumentProgram).catch(err => - LoopUtils.handleError(err, 'editor.synth.loadInstrument')); - } - } - } catch (err) { LoopUtils.handleError(err, 'editor.synth.setChannelInstrument'); } - - const range = this._gmNoteRange(this.instrumentProgram); - this.outputNoteMin = range.min; - this.outputNoteMax = range.max; - this._refreshPianoRollRange(); - // Propagate the new instrument to the MidiEditor panel so - // its specialized-mode toolbar (DRUM / TAB / WIND) and - // channel-routing logic stay in sync. - this.midiEditorPanel?.setPanelLoopState?.({ - channel: this.outputChannel, - instrumentProgram: this.instrumentProgram - }); - } - }); - - // Relocate the keyboard panel's own instrument selector into the - // loop editor's header so the user has a single, always-visible - // control — no matter which tab (Piano / Editor) is active. - // We move the live DOM rather than cloning so the keyboard - // panel's event handlers (which look the element up by ID) keep - // working without re-wiring. - this._relocateInstrumentSelectorToHeader(container); - } - - /** - * Move `#header-instrument-selector` (rendered by KeyboardModal - * inside the keyboard panel's modal-header) into `#le-instrument-host` - * in the loop editor's main header. Idempotent — does nothing if - * already relocated or if the host can't be found. - */ - _relocateInstrumentSelectorToHeader(kbContainer) { - const host = this.$('#le-instrument-host'); - if (!host) return; - // The keyboard panel is built asynchronously enough that the - // selector may not exist yet at mount return time on slow boots ; - // poll for it on a couple of RAFs before giving up. - const tryMove = (attempt = 0) => { - const sel = (kbContainer || document).querySelector('#header-instrument-selector') - || document.querySelector('#header-instrument-selector'); - if (sel) { - if (sel.parentElement !== host) host.appendChild(sel); - sel.classList.add('le-instrument-relocated'); - return; - } - if (attempt < 5) requestAnimationFrame(() => tryMove(attempt + 1)); - }; - tryMove(); - } - - _unmountKeyboardPanel() { - window.keyboardModal?.unmountPanel(); - } - - // ========================================================= - // PREVIEW PLAYBACK - // ========================================================= - - async _initSynth() { - this._synth = await LoopUtils.createSynth({ initialProgram: this.instrumentProgram }); - } - _previewLoop() { - this._stopAll(); - // Re-sync drum-kit flag from the keyboard panel in case - // `onInstrumentSelected` never fired (user never clicked the - // dropdown, mounted with a default device, etc.). Otherwise - // `_isDrumKit` stays false and the preview routes to channel 0 - // with a melodic program → silent or wrong sound. - this._reconcileDrumKitFromKeyboardPanel(); - const seq = this.pianoRollEditor?.getSequence() ?? []; - if (!seq.length) { this._setStatus(this.t('loopCreator.statusNoNotes')); return; } - if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { - this._previewViaDevice(seq); return; - } - if (!this._synth) { this._setStatus(this.t('loopCreator.statusNoSynth')); return; } - const done = () => { - this.isPlaying = false; - this._stopPlayheadAnimation(); - this._setStatus(''); - }; - try { - if (this._isDrumKit) { - this._synth.setChannelInstrument(9, this.instrumentProgram); - this._synth.loadDrumKit?.().catch(err => - LoopUtils.handleError(err, 'editor.preview.loadDrumKit')); - } else { - this._synth.setChannelInstrument(0, this.instrumentProgram); - } - } catch (err) { LoopUtils.handleError(err, 'editor.preview.setChannelInstrument'); } - this._synth.onPlaybackEnd = done; - this.isPlaying = true; - this._setStatus(this.t('loopCreator.statusPlaying')); - this._startPlayheadAnimation(); - // Force le canal 9 sur toutes les notes en mode drum kit, sinon - // loadSequence les met sur le canal 0 (path mélodique → piano). - const ch = this._isDrumKit ? 9 : 0; - const routedSeq = seq.map(n => ({ ...n, c: ch })); - this._synth.loadSequence(routedSeq, this.tempo, this.ppq); - this._synth.play().catch(err => { - LoopUtils.handleError(err, 'editor.preview.play', { - toast: this.t('loopEditor.errPreview') - }); - done(); + const range = this._gmNoteRange(this.instrumentProgram); + this.outputNoteMin = range.min; + this.outputNoteMax = range.max; + this._refreshPianoRollRange(); + // Propagate the new instrument to the MidiEditor panel so + // its specialized-mode toolbar (DRUM / TAB / WIND) and + // channel-routing logic stay in sync. + this.midiEditorPanel?.setPanelLoopState?.({ + channel: this.outputChannel, + instrumentProgram: this.instrumentProgram }); - } - - _previewViaDevice(seq) { - const spt = 60 / (this.tempo * this.ppq); - this.isPlaying = true; - this._setStatus(this.t('loopCreator.statusPlaying')); - this._startPlayheadAnimation(); - - // Note-off le plus tardif de la séquence : c'est lui qui définit - // la vraie fin de preview, pas la longueur logique du loop. Sans - // ça, une note finale qui dépasse la dernière mesure est coupée - // brutalement (AUDIT §L1). - let lastOffMs = this.ppq * this.timeSigNum * this.bars * spt * 1000; - - for (const note of seq) { - const onMs = note.t * spt * 1000; - const offMs = (note.t + (note.g || note.l || 120)) * spt * 1000; - if (offMs > lastOffMs) lastOffMs = offMs; - this._playbackTimers.push(setTimeout(() => { - if (!this.isPlaying) return; - this.api.sendCommand('midi_send_note', { - deviceId: this.outputDeviceId, channel: this.outputChannel, - note: note.n, velocity: note.v || 80 - }).catch(err => LoopUtils.handleError(err, 'editor.device.noteOn')); - }, onMs)); - this._playbackTimers.push(setTimeout(() => { - if (!this.isPlaying) return; - this.api.sendCommand('midi_send_note', { - deviceId: this.outputDeviceId, channel: this.outputChannel, - note: note.n, velocity: 0 - }).catch(err => LoopUtils.handleError(err, 'editor.device.noteOff')); - }, offMs)); - } - // 50 ms de marge pour laisser le note-off arriver côté device. - this._playbackTimers.push(setTimeout(() => { - this.isPlaying = false; this._setStatus(''); - }, lastOffMs + 50)); - } - - _stopAll() { - this._playbackTimers.forEach(t => clearTimeout(t)); - this._playbackTimers = []; - if (this.isRecording) this._stopRecording(); + } + }); + + // Relocate the keyboard panel's own instrument selector into the + // loop editor's header so the user has a single, always-visible + // control — no matter which tab (Piano / Editor) is active. + // We move the live DOM rather than cloning so the keyboard + // panel's event handlers (which look the element up by ID) keep + // working without re-wiring. + this._relocateInstrumentSelectorToHeader(container); + } + + /** + * Move `#header-instrument-selector` (rendered by KeyboardModal + * inside the keyboard panel's modal-header) into `#le-instrument-host` + * in the loop editor's main header. Idempotent — does nothing if + * already relocated or if the host can't be found. + */ + _relocateInstrumentSelectorToHeader(kbContainer) { + const host = this.$('#le-instrument-host'); + if (!host) return; + // The keyboard panel is built asynchronously enough that the + // selector may not exist yet at mount return time on slow boots ; + // poll for it on a couple of RAFs before giving up. + const tryMove = (attempt = 0) => { + const sel = + (kbContainer || document).querySelector('#header-instrument-selector') || + document.querySelector('#header-instrument-selector'); + if (sel) { + if (sel.parentElement !== host) host.appendChild(sel); + sel.classList.add('le-instrument-relocated'); + return; + } + if (attempt < 5) requestAnimationFrame(() => tryMove(attempt + 1)); + }; + tryMove(); + } + + _unmountKeyboardPanel() { + window.keyboardModal?.unmountPanel(); + } + + // ========================================================= + // PREVIEW PLAYBACK + // ========================================================= + + async _initSynth() { + this._synth = await LoopUtils.createSynth({ initialProgram: this.instrumentProgram }); + } + + _previewLoop() { + this._stopAll(); + // Re-sync drum-kit flag from the keyboard panel in case + // `onInstrumentSelected` never fired (user never clicked the + // dropdown, mounted with a default device, etc.). Otherwise + // `_isDrumKit` stays false and the preview routes to channel 0 + // with a melodic program → silent or wrong sound. + this._reconcileDrumKitFromKeyboardPanel(); + const seq = this.pianoRollEditor?.getSequence() ?? []; + if (!seq.length) { + this._setStatus(this.t('loopCreator.statusNoNotes')); + return; + } + if (this._outputTarget === 'live' && this.outputMode === 'device' && this.outputDeviceId) { + this._previewViaDevice(seq); + return; + } + if (!this._synth) { + this._setStatus(this.t('loopCreator.statusNoSynth')); + return; + } + const done = () => { + this.isPlaying = false; + this._stopPlayheadAnimation(); + this._setStatus(''); + }; + try { + if (this._isDrumKit) { + this._synth.setChannelInstrument(9, this.instrumentProgram); + this._synth + .loadDrumKit?.() + .catch((err) => LoopUtils.handleError(err, 'editor.preview.loadDrumKit')); + } else { + this._synth.setChannelInstrument(0, this.instrumentProgram); + } + } catch (err) { + LoopUtils.handleError(err, 'editor.preview.setChannelInstrument'); + } + this._synth.onPlaybackEnd = done; + this.isPlaying = true; + this._setStatus(this.t('loopCreator.statusPlaying')); + this._startPlayheadAnimation(); + // Force le canal 9 sur toutes les notes en mode drum kit, sinon + // loadSequence les met sur le canal 0 (path mélodique → piano). + const ch = this._isDrumKit ? 9 : 0; + const routedSeq = seq.map((n) => ({ ...n, c: ch })); + this._synth.loadSequence(routedSeq, this.tempo, this.ppq); + this._synth.play().catch((err) => { + LoopUtils.handleError(err, 'editor.preview.play', { + toast: this.t('loopEditor.errPreview') + }); + done(); + }); + } + + _previewViaDevice(seq) { + const spt = 60 / (this.tempo * this.ppq); + this.isPlaying = true; + this._setStatus(this.t('loopCreator.statusPlaying')); + this._startPlayheadAnimation(); + + // Note-off le plus tardif de la séquence : c'est lui qui définit + // la vraie fin de preview, pas la longueur logique du loop. Sans + // ça, une note finale qui dépasse la dernière mesure est coupée + // brutalement (AUDIT §L1). + let lastOffMs = this.ppq * this.timeSigNum * this.bars * spt * 1000; + + for (const note of seq) { + const onMs = note.t * spt * 1000; + const offMs = (note.t + (note.g || note.l || 120)) * spt * 1000; + if (offMs > lastOffMs) lastOffMs = offMs; + this._playbackTimers.push( + setTimeout(() => { + if (!this.isPlaying) return; + this.api + .sendCommand('midi_send_note', { + deviceId: this.outputDeviceId, + channel: this.outputChannel, + note: note.n, + velocity: note.v || 80 + }) + .catch((err) => LoopUtils.handleError(err, 'editor.device.noteOn')); + }, onMs) + ); + this._playbackTimers.push( + setTimeout(() => { + if (!this.isPlaying) return; + this.api + .sendCommand('midi_send_note', { + deviceId: this.outputDeviceId, + channel: this.outputChannel, + note: note.n, + velocity: 0 + }) + .catch((err) => LoopUtils.handleError(err, 'editor.device.noteOff')); + }, offMs) + ); + } + // 50 ms de marge pour laisser le note-off arriver côté device. + this._playbackTimers.push( + setTimeout(() => { this.isPlaying = false; - this._stopPlayheadAnimation(); - if (this._synth) { - this._synth.onPlaybackEnd = null; - try { this._synth.stop?.(); } - catch (err) { LoopUtils.handleError(err, 'editor.synth.stop'); } - try { this._synth.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'editor.synth.cancelAllNotes'); } - } - // Cancel sustained live-preview voices that were still ringing - this._previewStopAll(); - this._activeKeys.clear(); this._setStatus(''); - } - - // ========================================================= - // SAVE / LOAD - // ========================================================= - - /** - * Safety net before saving : if the keyboard panel's current state - * indicates a drum kit (drumpad view OR caps.instrument_type in the - * drum-like set OR selected device on channel 9 OR gm_program ≥ 128) - * but our local `_isDrumKit` / `instrumentProgram` got out of sync, - * rewrite them here so the saved loop's `instrument_program` lands - * in the drum-kit-offset range (≥ 128). Mirrors the logic of - * `_selectInstrumentOption` minus the synth setup (the synth was - * already correctly configured at recording time if drums sounded). - */ - _reconcileDrumKitFromKeyboardPanel() { - const kbd = window.keyboardModal; - if (!kbd) return; - const caps = kbd.selectedDeviceCapabilities || null; - const type = (caps?.instrument_type || '').toLowerCase(); - const drumLikeTypes = new Set(['drum', 'drums', 'drumkit', 'drum_kit', 'percussion', 'percussive']); - const ch = caps?.channel ?? kbd.selectedDevice?.channel ?? null; - const gm = caps?.gm_program ?? kbd.selectedDevice?.gm_program ?? null; - const isDrum = kbd.viewMode === 'drumpad' - || drumLikeTypes.has(type) - || ch === 9 - || (gm != null && gm >= 128); - if (!isDrum) return; - // The current instrument is a kit. Force the in-memory state to - // match so `_saveLoop` writes a drum-kit `instrument_program`. - const rawProgram = gm ?? 0; - const stored = rawProgram >= 128 ? rawProgram : rawProgram + 128; - if (this.instrumentProgram !== stored || !this._isDrumKit) { - this.instrumentProgram = stored; - this._isDrumKit = true; - this.outputChannel = 9; - this.outputGmProgram = stored; - this._instrumentSelected = true; - this._refreshRecButtonEnabled?.(); - } - } - - async _saveLoop({ asNew = false } = {}) { - this.loopName = (this.$('#lc-name-input')?.value?.trim()) || this.t('loopCreator.untitled'); - if (asNew) { - this.loopName = this.t('loopManager.duplicateNameSuffix', { name: this.loopName }); - const nameEl = this.$('#lc-name-input'); if (nameEl) nameEl.value = this.loopName; - } - - // Defensive sync : if the keyboard panel currently shows the - // drum-pad view (or its capabilities say drum / drums / - // percussion), force the saved program to a drum-kit encoding - // even if our local `_isDrumKit` got out of sync (e.g. the user - // never clicked the dropdown so `onInstrumentSelected` never - // fired). Without this guard, a "I picked drums but never got - // a callback" path would save the loop with a melodic program. - this._reconcileDrumKitFromKeyboardPanel(); - - // webaudio-pianoroll stores note length as `g` (gate), but the - // backend's loop schema validates against `l` (length). Normalise - // here so the same in-memory sequence works regardless of whether - // it was recorded, edited via the panel, or loaded from DB. - const rawSeq = this.pianoRollEditor?.getSequence() ?? []; - const seq = rawSeq.map(n => { - const len = Number.isInteger(n.l) && n.l > 0 ? n.l - : Number.isInteger(n.g) && n.g > 0 ? n.g - : 120; - return { t: n.t | 0, n: n.n | 0, v: (n.v ?? 80) | 0, l: len }; - }); - const payload = { - name: this.loopName, tempo: this.tempo, - time_sig_num: this.timeSigNum, time_sig_den: this.timeSigDen, - bars: this.bars, ppq: this.ppq, - instrument_program: this.instrumentProgram, - midi_data: JSON.stringify(seq) - }; - try { - if (this.currentLoopId && !asNew) { - await this.api.sendCommand('loop_update', { loopId: this.currentLoopId, ...payload }); - } else { - const r = await this.api.sendCommand('loop_create', payload); - this.currentLoopId = r.loopId; - } - const titleEl = this.dialog?.querySelector('.le-header-title'); - if (titleEl) titleEl.textContent = `✏️ ${this.loopName}`; - this._setStatus(this.t('loopCreator.statusSaved')); - LoopUtils.toast(this.t('loopCreator.statusSaved'), 'success'); - this._markSaved(); - this.onSaved?.(this.currentLoopId); - } catch (err) { - this._setStatus(`${this.t('loopCreator.statusError')}: ${err.message}`); - LoopUtils.handleError(err, 'editor.save', { - toast: `${this.t('loopCreator.statusError')}: ${err.message}` - }); - } - } - - // ========================================================= - // HELPERS - // ========================================================= - - _setStatus(msg) { const el = this.$('#lc-status'); if (el) el.textContent = msg; } + }, lastOffMs + 50) + ); + } + + _stopAll() { + this._playbackTimers.forEach((t) => clearTimeout(t)); + this._playbackTimers = []; + if (this.isRecording) this._stopRecording(); + this.isPlaying = false; + this._stopPlayheadAnimation(); + if (this._synth) { + this._synth.onPlaybackEnd = null; + try { + this._synth.stop?.(); + } catch (err) { + LoopUtils.handleError(err, 'editor.synth.stop'); + } + try { + this._synth.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'editor.synth.cancelAllNotes'); + } + } + // Cancel sustained live-preview voices that were still ringing + this._previewStopAll(); + this._activeKeys.clear(); + this._setStatus(''); + } + + // ========================================================= + // SAVE / LOAD + // ========================================================= + + /** + * Safety net before saving : if the keyboard panel's current state + * indicates a drum kit (drumpad view OR caps.instrument_type in the + * drum-like set OR selected device on channel 9 OR gm_program ≥ 128) + * but our local `_isDrumKit` / `instrumentProgram` got out of sync, + * rewrite them here so the saved loop's `instrument_program` lands + * in the drum-kit-offset range (≥ 128). Mirrors the logic of + * `_selectInstrumentOption` minus the synth setup (the synth was + * already correctly configured at recording time if drums sounded). + */ + _reconcileDrumKitFromKeyboardPanel() { + const kbd = window.keyboardModal; + if (!kbd) return; + const caps = kbd.selectedDeviceCapabilities || null; + const type = (caps?.instrument_type || '').toLowerCase(); + const drumLikeTypes = new Set([ + 'drum', + 'drums', + 'drumkit', + 'drum_kit', + 'percussion', + 'percussive' + ]); + const ch = caps?.channel ?? kbd.selectedDevice?.channel ?? null; + const gm = caps?.gm_program ?? kbd.selectedDevice?.gm_program ?? null; + const isDrum = + kbd.viewMode === 'drumpad' || + drumLikeTypes.has(type) || + ch === 9 || + (gm != null && gm >= 128); + if (!isDrum) return; + // The current instrument is a kit. Force the in-memory state to + // match so `_saveLoop` writes a drum-kit `instrument_program`. + const rawProgram = gm ?? 0; + const stored = rawProgram >= 128 ? rawProgram : rawProgram + 128; + if (this.instrumentProgram !== stored || !this._isDrumKit) { + this.instrumentProgram = stored; + this._isDrumKit = true; + this.outputChannel = 9; + this.outputGmProgram = stored; + this._instrumentSelected = true; + this._refreshRecButtonEnabled?.(); + } + } + + async _saveLoop({ asNew = false } = {}) { + this.loopName = this.$('#lc-name-input')?.value?.trim() || this.t('loopCreator.untitled'); + if (asNew) { + this.loopName = this.t('loopManager.duplicateNameSuffix', { name: this.loopName }); + const nameEl = this.$('#lc-name-input'); + if (nameEl) nameEl.value = this.loopName; + } + + // Defensive sync : if the keyboard panel currently shows the + // drum-pad view (or its capabilities say drum / drums / + // percussion), force the saved program to a drum-kit encoding + // even if our local `_isDrumKit` got out of sync (e.g. the user + // never clicked the dropdown so `onInstrumentSelected` never + // fired). Without this guard, a "I picked drums but never got + // a callback" path would save the loop with a melodic program. + this._reconcileDrumKitFromKeyboardPanel(); + + // webaudio-pianoroll stores note length as `g` (gate), but the + // backend's loop schema validates against `l` (length). Normalise + // here so the same in-memory sequence works regardless of whether + // it was recorded, edited via the panel, or loaded from DB. + const rawSeq = this.pianoRollEditor?.getSequence() ?? []; + const seq = rawSeq.map((n) => { + const len = + Number.isInteger(n.l) && n.l > 0 ? n.l : Number.isInteger(n.g) && n.g > 0 ? n.g : 120; + return { t: n.t | 0, n: n.n | 0, v: (n.v ?? 80) | 0, l: len }; + }); + const payload = { + name: this.loopName, + tempo: this.tempo, + time_sig_num: this.timeSigNum, + time_sig_den: this.timeSigDen, + bars: this.bars, + ppq: this.ppq, + instrument_program: this.instrumentProgram, + midi_data: JSON.stringify(seq) + }; + try { + if (this.currentLoopId && !asNew) { + await this.api.sendCommand('loop_update', { loopId: this.currentLoopId, ...payload }); + } else { + const r = await this.api.sendCommand('loop_create', payload); + this.currentLoopId = r.loopId; + } + const titleEl = this.dialog?.querySelector('.le-header-title'); + if (titleEl) titleEl.textContent = `✏️ ${this.loopName}`; + this._setStatus(this.t('loopCreator.statusSaved')); + LoopUtils.toast(this.t('loopCreator.statusSaved'), 'success'); + this._markSaved(); + this.onSaved?.(this.currentLoopId); + } catch (err) { + this._setStatus(`${this.t('loopCreator.statusError')}: ${err.message}`); + LoopUtils.handleError(err, 'editor.save', { + toast: `${this.t('loopCreator.statusError')}: ${err.message}` + }); + } + } + + // ========================================================= + // HELPERS + // ========================================================= + + _setStatus(msg) { + const el = this.$('#lc-status'); + if (el) el.textContent = msg; + } } if (typeof window !== 'undefined') window.LoopEditorModal = LoopEditorModal; diff --git a/public/js/features/LoopManagerKeyboardFeature.js b/public/js/features/LoopManagerKeyboardFeature.js index 5c5fe304f..9a4d28bdf 100644 --- a/public/js/features/LoopManagerKeyboardFeature.js +++ b/public/js/features/LoopManagerKeyboardFeature.js @@ -19,197 +19,235 @@ * - mounted (boolean) — current state */ (function () { - 'use strict'; + 'use strict'; - class LoopManagerKeyboardFeature { - /** @param {LoopManagerModal} modal */ - constructor(modal) { - this.modal = modal; - this.synth = null; - this.mounted = false; - this.envelopes = new Map(); // note → [envelope, ...] - this.activeKeys = new Set(); - this.instrument = 0; // GM program (0-127) - this.isDrum = false; - } + class LoopManagerKeyboardFeature { + /** @param {LoopManagerModal} modal */ + constructor(modal) { + this.modal = modal; + this.synth = null; + this.mounted = false; + this.envelopes = new Map(); // note → [envelope, ...] + this.activeKeys = new Set(); + this.instrument = 0; // GM program (0-127) + this.isDrum = false; + } - /** - * Activate the keyboard tab: lazy-init the local synth and mount - * the shared `keyboardModal` piano panel. - */ - async enterTab() { - if (!this.synth) { - this.synth = await LoopUtils.createSynth({ initialProgram: this.instrument }); - } - if (!this.mounted) this._mountPanel(); - } + /** + * Activate the keyboard tab: lazy-init the local synth and mount + * the shared `keyboardModal` piano panel. + */ + async enterTab() { + if (!this.synth) { + this.synth = await LoopUtils.createSynth({ initialProgram: this.instrument }); + } + if (!this.mounted) this._mountPanel(); + } - _mountPanel() { - const container = this.modal.$('#lm-kbd-panel'); - if (!container || !window.keyboardModal) return; - if (window.keyboardModal._panelMode) { - // Editor (or another host) currently owns the keyboard panel — - // give them precedence; we'll mount on next tab activation. - return; + _mountPanel() { + const container = this.modal.$('#lm-kbd-panel'); + if (!container || !window.keyboardModal) return; + if (window.keyboardModal._panelMode) { + // Editor (or another host) currently owns the keyboard panel — + // give them precedence; we'll mount on next tab activation. + return; + } + try { + window.keyboardModal.mountAsPanel(container, { + onNoteOn: (note, vel) => this.noteOn(note, vel), + onNoteOff: (note) => this.noteOff(note), + onInstrumentSelected: ({ + deviceId, + channel, + gmProgram, + instrumentType, + isDrum: isDrumFromKbd + }) => { + // Cancel any sustained voice before switching instrument / + // device so it doesn't keep ringing on the previous program. + this.stopAllNotes(); + this.instrument = gmProgram ?? 0; + // Drum kit : on route sur le canal 9 (convention GM) sinon + // le synth utilise le path mélodique → joue un piano. + this.isDrum = + isDrumFromKbd === true || + instrumentType === 'drum' || + channel === 9 || + (gmProgram != null && gmProgram >= 128); + if (this.synth) { + try { + if (this.isDrum) { + this.synth.setChannelInstrument(9, this.instrument); + this.synth + .loadDrumKit?.() + .catch((err) => LoopUtils.handleError(err, 'kbd.synth.loadDrumKit')); + } else { + this.synth.setChannelInstrument(0, this.instrument); + if (!this.synth.loadedInstruments?.has(this.instrument)) { + this.synth + .loadInstrument(this.instrument) + .catch((err) => LoopUtils.handleError(err, 'kbd.synth.loadInstrument')); + } + } + } catch (err) { + LoopUtils.handleError(err, 'kbd.synth.setChannelInstrument'); + } } - try { - window.keyboardModal.mountAsPanel(container, { - onNoteOn: (note, vel) => this.noteOn(note, vel), - onNoteOff: (note) => this.noteOff(note), - onInstrumentSelected: ({ deviceId, channel, gmProgram, instrumentType, isDrum: isDrumFromKbd }) => { - // Cancel any sustained voice before switching instrument / - // device so it doesn't keep ringing on the previous program. - this.stopAllNotes(); - this.instrument = gmProgram ?? 0; - // Drum kit : on route sur le canal 9 (convention GM) sinon - // le synth utilise le path mélodique → joue un piano. - this.isDrum = isDrumFromKbd === true - || instrumentType === 'drum' - || channel === 9 - || (gmProgram != null && gmProgram >= 128); - if (this.synth) { - try { - if (this.isDrum) { - this.synth.setChannelInstrument(9, this.instrument); - this.synth.loadDrumKit?.().catch(err => - LoopUtils.handleError(err, 'kbd.synth.loadDrumKit')); - } else { - this.synth.setChannelInstrument(0, this.instrument); - if (!this.synth.loadedInstruments?.has(this.instrument)) { - this.synth.loadInstrument(this.instrument).catch(err => - LoopUtils.handleError(err, 'kbd.synth.loadInstrument')); - } - } - } catch (err) { LoopUtils.handleError(err, 'kbd.synth.setChannelInstrument'); } - } - // The keyboard panel's instrument selector only updates - // the routed device/channel — it never flips the - // synth/device mode. The header toggle (toggleMode) is - // the single source of truth for the mode, so the - // user's choice persists across instrument changes. - if (deviceId) { - // mode is intentionally omitted: setOutput() - // merge-patches, so the current mode is preserved. - this.modal._setGlobalOutput({ - deviceId, - channel: channel ?? 0 - }); - } - // "Preview" / no device picked → leave mode and the - // previously-picked deviceId untouched so the header - // toggle can still route to that device later. - } - }); - this.mounted = true; - } catch (err) { - LoopUtils.handleError(err, 'kbd.mount', { - toast: this.modal.t('loopManager.errKbdMount') - }); + // The keyboard panel's instrument selector only updates + // the routed device/channel — it never flips the + // synth/device mode. The header toggle (toggleMode) is + // the single source of truth for the mode, so the + // user's choice persists across instrument changes. + if (deviceId) { + // mode is intentionally omitted: setOutput() + // merge-patches, so the current mode is preserved. + this.modal._setGlobalOutput({ + deviceId, + channel: channel ?? 0 + }); } - } + // "Preview" / no device picked → leave mode and the + // previously-picked deviceId untouched so the header + // toggle can still route to that device later. + } + }); + this.mounted = true; + } catch (err) { + LoopUtils.handleError(err, 'kbd.mount', { + toast: this.modal.t('loopManager.errKbdMount') + }); + } + } - unmount() { - try { window.keyboardModal?.unmountPanel?.(); } - catch (err) { LoopUtils.handleError(err, 'kbd.unmount'); } - this.mounted = false; - this.stopAllNotes(); - } + unmount() { + try { + window.keyboardModal?.unmountPanel?.(); + } catch (err) { + LoopUtils.handleError(err, 'kbd.unmount'); + } + this.mounted = false; + this.stopAllNotes(); + } - /** - * The header toggle is the single source of truth for routing: - * when `_globalOutput.mode === 'device'` notes go to the picked - * device, otherwise they go to the local preview synth. - * @returns {{deviceId:string, channel:number}|null} - */ - _routingDevice() { - const out = this.modal._globalOutput; - return out.mode === 'device' && out.deviceId - ? { deviceId: out.deviceId, channel: out.channel ?? 0 } - : null; - } + /** + * The header toggle is the single source of truth for routing: + * when `_globalOutput.mode === 'device'` notes go to the picked + * device, otherwise they go to the local preview synth. + * @returns {{deviceId:string, channel:number}|null} + */ + _routingDevice() { + const out = this.modal._globalOutput; + return out.mode === 'device' && out.deviceId + ? { deviceId: out.deviceId, channel: out.channel ?? 0 } + : null; + } - noteOn(note, velocity = 80) { - if (this.activeKeys.has(note)) return; - this.activeKeys.add(note); - const route = this._routingDevice(); - if (route) { - this.modal.api.sendCommand('midi_send_note', { - deviceId: route.deviceId, channel: route.channel, - note, velocity - }).catch(err => LoopUtils.handleError(err, 'kbd.live.noteOn')); - return; - } - if (!this.synth) return; - // Canal 9 si le kit drum est sélectionné, sinon 0 (mélodique). - const ch = this.isDrum ? 9 : 0; - // Drum mode: if loadDrumKit() is still in flight (user clicked - // before the presets landed), defer the playNote until it - // resolves. Without this the first frappe is silent — playNote - // would lazy-load + return null. Melodic path stays sync. - if (ch === 9 && typeof this.synth.ensureDrumKitReady === 'function' - && this.synth._drumKitLoading) { - this.synth.ensureDrumKitReady().then(() => { - if (!this.activeKeys.has(note)) return; // released early - try { - const env = this.synth.playNote(note, velocity, 9, 9999); - if (env) this.envelopes.set(note, env); - } catch (err) { - LoopUtils.handleError(err, 'kbd.synth.playNote'); - } - }); - return; - } - try { - const env = this.synth.playNote(note, velocity, ch, 9999); - if (env) this.envelopes.set(note, env); - } catch (err) { - LoopUtils.handleError(err, 'kbd.synth.playNote'); - } - } + noteOn(note, velocity = 80) { + if (this.activeKeys.has(note)) return; + this.activeKeys.add(note); + const route = this._routingDevice(); + if (route) { + this.modal.api + .sendCommand('midi_send_note', { + deviceId: route.deviceId, + channel: route.channel, + note, + velocity + }) + .catch((err) => LoopUtils.handleError(err, 'kbd.live.noteOn')); + return; + } + if (!this.synth) return; + // Canal 9 si le kit drum est sélectionné, sinon 0 (mélodique). + const ch = this.isDrum ? 9 : 0; + // Drum mode: if loadDrumKit() is still in flight (user clicked + // before the presets landed), defer the playNote until it + // resolves. Without this the first frappe is silent — playNote + // would lazy-load + return null. Melodic path stays sync. + if ( + ch === 9 && + typeof this.synth.ensureDrumKitReady === 'function' && + this.synth._drumKitLoading + ) { + this.synth.ensureDrumKitReady().then(() => { + if (!this.activeKeys.has(note)) return; // released early + try { + const env = this.synth.playNote(note, velocity, 9, 9999); + if (env) this.envelopes.set(note, env); + } catch (err) { + LoopUtils.handleError(err, 'kbd.synth.playNote'); + } + }); + return; + } + try { + const env = this.synth.playNote(note, velocity, ch, 9999); + if (env) this.envelopes.set(note, env); + } catch (err) { + LoopUtils.handleError(err, 'kbd.synth.playNote'); + } + } - noteOff(note) { - this.activeKeys.delete(note); - const route = this._routingDevice(); - if (route) { - this.modal.api.sendCommand('midi_send_note', { - deviceId: route.deviceId, channel: route.channel, - note, velocity: 0 - }).catch(err => LoopUtils.handleError(err, 'kbd.live.noteOff')); - return; - } - const env = this.envelopes.get(note); - if (!env) return; - for (const e of env) { - try { e?.cancel?.(); } - catch (err) { LoopUtils.handleError(err, 'kbd.synth.cancel'); } - } - this.envelopes.delete(note); + noteOff(note) { + this.activeKeys.delete(note); + const route = this._routingDevice(); + if (route) { + this.modal.api + .sendCommand('midi_send_note', { + deviceId: route.deviceId, + channel: route.channel, + note, + velocity: 0 + }) + .catch((err) => LoopUtils.handleError(err, 'kbd.live.noteOff')); + return; + } + const env = this.envelopes.get(note); + if (!env) return; + for (const e of env) { + try { + e?.cancel?.(); + } catch (err) { + LoopUtils.handleError(err, 'kbd.synth.cancel'); } + } + this.envelopes.delete(note); + } - stopAllNotes() { - // Live device: send a note-off for every held note before clearing. - const route = this._routingDevice(); - if (route && this.activeKeys.size) { - for (const n of this.activeKeys) { - this.modal.api.sendCommand('midi_send_note', { - deviceId: route.deviceId, channel: route.channel, - note: n, velocity: 0 - }).catch(err => LoopUtils.handleError(err, 'kbd.live.flushNoteOff')); - } - } - // Synth: cancel any envelopes still ringing. - for (const env of this.envelopes.values()) { - for (const e of env) { try { e?.cancel?.(); } catch (_) { /* best-effort */ } } - } - this.envelopes.clear(); - this.activeKeys.clear(); + stopAllNotes() { + // Live device: send a note-off for every held note before clearing. + const route = this._routingDevice(); + if (route && this.activeKeys.size) { + for (const n of this.activeKeys) { + this.modal.api + .sendCommand('midi_send_note', { + deviceId: route.deviceId, + channel: route.channel, + note: n, + velocity: 0 + }) + .catch((err) => LoopUtils.handleError(err, 'kbd.live.flushNoteOff')); } + } + // Synth: cancel any envelopes still ringing. + for (const env of this.envelopes.values()) { + for (const e of env) { + try { + e?.cancel?.(); + } catch (_) { + /* best-effort */ + } + } + } + this.envelopes.clear(); + this.activeKeys.clear(); } + } - if (typeof window !== 'undefined') { - window.LoopManagerKeyboardFeature = LoopManagerKeyboardFeature; - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopManagerKeyboardFeature; - } + if (typeof window !== 'undefined') { + window.LoopManagerKeyboardFeature = LoopManagerKeyboardFeature; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopManagerKeyboardFeature; + } })(); diff --git a/public/js/features/LoopManagerLibraryFeature.js b/public/js/features/LoopManagerLibraryFeature.js index ce1bba63a..068129971 100644 --- a/public/js/features/LoopManagerLibraryFeature.js +++ b/public/js/features/LoopManagerLibraryFeature.js @@ -31,61 +31,76 @@ * - getLibrary() — array (used by other features) */ (function () { - 'use strict'; - - class LoopManagerLibraryFeature { - /** - * @param {LoopManagerModal} modal - * @param {Object} callbacks - * @param {(id:number)=>void} callbacks.onDeleteLoop - * @param {(id:number)=>void} callbacks.onOpenLoopEditor - * @param {()=>void} [callbacks.onLibraryLoaded] - */ - constructor(modal, callbacks = {}) { - this.modal = modal; - this.callbacks = callbacks; - this.library = []; - this.search = ''; - this.filter = ''; // instrument_program as string, or '' for all - this.sort = 'name'; - } + 'use strict'; + + class LoopManagerLibraryFeature { + /** + * @param {LoopManagerModal} modal + * @param {Object} callbacks + * @param {(id:number)=>void} callbacks.onDeleteLoop + * @param {(id:number)=>void} callbacks.onOpenLoopEditor + * @param {()=>void} [callbacks.onLibraryLoaded] + */ + constructor(modal, callbacks = {}) { + this.modal = modal; + this.callbacks = callbacks; + this.library = []; + this.search = ''; + this.filter = ''; // instrument_program as string, or '' for all + this.sort = 'name'; + } + + // ------------------------------------------------------------- + // State setters — called from the modal's _onChange / _onInput. + // ------------------------------------------------------------- + + setSearch(value) { + this.search = value || ''; + this.filterAndRender(); + } + setFilter(value) { + this.filter = value || ''; + this.filterAndRender(); + } + setSort(value) { + this.sort = value || 'name'; + this.filterAndRender(); + } + getLibrary() { + return this.library; + } - // ------------------------------------------------------------- - // State setters — called from the modal's _onChange / _onInput. - // ------------------------------------------------------------- - - setSearch(value) { this.search = value || ''; this.filterAndRender(); } - setFilter(value) { this.filter = value || ''; this.filterAndRender(); } - setSort(value) { this.sort = value || 'name'; this.filterAndRender(); } - getLibrary() { return this.library; } - - // ------------------------------------------------------------- - // Async load - // ------------------------------------------------------------- - - async loadLibrary() { - try { - const r = await this.modal.api.sendCommand('loop_list'); - this.library = r.loops || []; - if (this.modal.activeTab === 'library') this.filterAndRender(); - if (typeof this.callbacks.onLibraryLoaded === 'function') { - try { this.callbacks.onLibraryLoaded(); } catch (_) { /* best-effort */ } - } - } catch (err) { - LoopUtils.handleError(err, 'manager.loadLibrary', { - toast: this.modal.t('loopManager.errLoadLibrary') - }); - } + // ------------------------------------------------------------- + // Async load + // ------------------------------------------------------------- + + async loadLibrary() { + try { + const r = await this.modal.api.sendCommand('loop_list'); + this.library = r.loops || []; + if (this.modal.activeTab === 'library') this.filterAndRender(); + if (typeof this.callbacks.onLibraryLoaded === 'function') { + try { + this.callbacks.onLibraryLoaded(); + } catch (_) { + /* best-effort */ + } } + } catch (err) { + LoopUtils.handleError(err, 'manager.loadLibrary', { + toast: this.modal.t('loopManager.errLoadLibrary') + }); + } + } - // ------------------------------------------------------------- - // Tab template - // ------------------------------------------------------------- + // ------------------------------------------------------------- + // Tab template + // ------------------------------------------------------------- - renderTabHtml() { - const t = this.modal.t.bind(this.modal); - return ` -
+ renderTabHtml() { + const t = this.modal.t.bind(this.modal); + return ` +
@@ -111,104 +126,110 @@
`; - } + } - // ------------------------------------------------------------- - // Render pipeline - // ------------------------------------------------------------- - - filterAndRender() { - const grid = this.modal.$('#lm-library-grid'); - if (!grid) return; - - this._populateInstrumentFilter(); - - let items = [...this.library]; - if (this.search) { - const q = this.search.toLowerCase(); - items = items.filter(l => l.name.toLowerCase().includes(q)); - } - if (this.filter !== '') { - const prog = parseInt(this.filter); - items = items.filter(l => (l.instrument_program ?? 0) === prog); - } - items.sort((a, b) => { - if (this.sort === 'tempo') return a.tempo - b.tempo; - if (this.sort === 'bars') return a.bars - b.bars; - if (this.sort === 'instrument') return (a.instrument_program ?? 0) - (b.instrument_program ?? 0); - return a.name.localeCompare(b.name); - }); - - if (!items.length) { - grid.innerHTML = `
${this.modal.t('loopCreator.libraryEmpty')}
`; - return; - } - grid.innerHTML = items.map(loop => this._loopCardHtml(loop)).join(''); - - if (!grid.dataset.lmWired) { - grid.dataset.lmWired = '1'; - grid.addEventListener('click', (e) => { - const btn = e.target.closest('[data-loop-action]'); - if (!btn) return; - const id = parseInt(btn.dataset.loopId); - if (btn.dataset.loopAction === 'edit') { - this.callbacks.onOpenLoopEditor?.(id); - } - if (btn.dataset.loopAction === 'delete') { - this.callbacks.onDeleteLoop?.(id); - } - }); - // Cards are draggable → drop on pads, palette chips, etc. - grid.addEventListener('dragstart', (e) => { - const card = e.target.closest('.lc-card[data-loop-id]'); - if (!card) return; - const id = parseInt(card.dataset.loopId); - const loop = this.library.find(l => l.id === id); - if (!loop) return; - e.dataTransfer.effectAllowed = 'copy'; - e.dataTransfer.setData('text/plain', JSON.stringify({ - source: 'library-card', - loopId: id, - loopBars: loop.bars, - loopName: loop.name - })); - card.classList.add('lc-card--dragging'); - }); - grid.addEventListener('dragend', (e) => { - const card = e.target.closest('.lc-card[data-loop-id]'); - if (card) card.classList.remove('lc-card--dragging'); - }); - } - } + // ------------------------------------------------------------- + // Render pipeline + // ------------------------------------------------------------- + + filterAndRender() { + const grid = this.modal.$('#lm-library-grid'); + if (!grid) return; + + this._populateInstrumentFilter(); + + let items = [...this.library]; + if (this.search) { + const q = this.search.toLowerCase(); + items = items.filter((l) => l.name.toLowerCase().includes(q)); + } + if (this.filter !== '') { + const prog = parseInt(this.filter); + items = items.filter((l) => (l.instrument_program ?? 0) === prog); + } + items.sort((a, b) => { + if (this.sort === 'tempo') return a.tempo - b.tempo; + if (this.sort === 'bars') return a.bars - b.bars; + if (this.sort === 'instrument') + return (a.instrument_program ?? 0) - (b.instrument_program ?? 0); + return a.name.localeCompare(b.name); + }); + + if (!items.length) { + grid.innerHTML = `
${this.modal.t('loopCreator.libraryEmpty')}
`; + return; + } + grid.innerHTML = items.map((loop) => this._loopCardHtml(loop)).join(''); + + if (!grid.dataset.lmWired) { + grid.dataset.lmWired = '1'; + grid.addEventListener('click', (e) => { + const btn = e.target.closest('[data-loop-action]'); + if (!btn) return; + const id = parseInt(btn.dataset.loopId); + if (btn.dataset.loopAction === 'edit') { + this.callbacks.onOpenLoopEditor?.(id); + } + if (btn.dataset.loopAction === 'delete') { + this.callbacks.onDeleteLoop?.(id); + } + }); + // Cards are draggable → drop on pads, palette chips, etc. + grid.addEventListener('dragstart', (e) => { + const card = e.target.closest('.lc-card[data-loop-id]'); + if (!card) return; + const id = parseInt(card.dataset.loopId); + const loop = this.library.find((l) => l.id === id); + if (!loop) return; + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData( + 'text/plain', + JSON.stringify({ + source: 'library-card', + loopId: id, + loopBars: loop.bars, + loopName: loop.name + }) + ); + card.classList.add('lc-card--dragging'); + }); + grid.addEventListener('dragend', (e) => { + const card = e.target.closest('.lc-card[data-loop-id]'); + if (card) card.classList.remove('lc-card--dragging'); + }); + } + } - _populateInstrumentFilter() { - const sel = this.modal.$('#lm-lib-filter'); - if (!sel) return; - const programs = [...new Set(this.library.map(l => l.instrument_program ?? 0))].sort((a, b) => a - b); - const current = sel.value; - sel.innerHTML = ``; - for (const prog of programs) { - const opt = document.createElement('option'); - opt.value = prog; - opt.textContent = this.modal._gmProgramName(prog); - if (String(prog) === (current || String(this.filter))) opt.selected = true; - sel.appendChild(opt); - } - } + _populateInstrumentFilter() { + const sel = this.modal.$('#lm-lib-filter'); + if (!sel) return; + const programs = [...new Set(this.library.map((l) => l.instrument_program ?? 0))].sort( + (a, b) => a - b + ); + const current = sel.value; + sel.innerHTML = ``; + for (const prog of programs) { + const opt = document.createElement('option'); + opt.value = prog; + opt.textContent = this.modal._gmProgramName(prog); + if (String(prog) === (current || String(this.filter))) opt.selected = true; + sel.appendChild(opt); + } + } - _loopCardHtml(loop) { - const m = this.modal; - const prog = loop.instrument_program ?? 0; - const family = LoopUtils.familyForProgram(prog); - const instrName = m._gmProgramName(prog); - const padIndexes = m._padSlots - .map((s, i) => s?.loopId === loop.id ? (i + 1) : null) - .filter(x => x != null); - const padTagHtml = padIndexes.length - ? `📌 ${padIndexes.join(',')}` - : ''; - const playing = m._livePlayingLoops.has(loop.id); - return `
+ _loopCardHtml(loop) { + const m = this.modal; + const prog = loop.instrument_program ?? 0; + const family = LoopUtils.familyForProgram(prog); + const instrName = m._gmProgramName(prog); + const padIndexes = m._padSlots + .map((s, i) => (s?.loopId === loop.id ? i + 1 : null)) + .filter((x) => x != null); + const padTagHtml = padIndexes.length + ? `📌 ${padIndexes.join(',')}` + : ''; + const playing = m._livePlayingLoops.has(loop.id); + return `
${m._instrIconHtml(prog, 'instrument', 'lc-card-icon')} ${m.escape(loop.name)} @@ -220,13 +241,13 @@
`; - } - } - - if (typeof window !== 'undefined') { - window.LoopManagerLibraryFeature = LoopManagerLibraryFeature; - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopManagerLibraryFeature; } + } + + if (typeof window !== 'undefined') { + window.LoopManagerLibraryFeature = LoopManagerLibraryFeature; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopManagerLibraryFeature; + } })(); diff --git a/public/js/features/LoopManagerLiveFeature.js b/public/js/features/LoopManagerLiveFeature.js index 982917ee2..474ba356c 100644 --- a/public/js/features/LoopManagerLiveFeature.js +++ b/public/js/features/LoopManagerLiveFeature.js @@ -26,30 +26,30 @@ * - synth (readonly) — exposed for back-compat */ (function () { - 'use strict'; + 'use strict'; - class LoopManagerLiveFeature { - /** @param {LoopManagerModal} modal */ - constructor(modal) { - this.modal = modal; - this.synth = null; - this.playingLoops = new Map(); // loopId → { timers, ch, startMs, durMs } - this.search = ''; - } + class LoopManagerLiveFeature { + /** @param {LoopManagerModal} modal */ + constructor(modal) { + this.modal = modal; + this.synth = null; + this.playingLoops = new Map(); // loopId → { timers, ch, startMs, durMs } + this.search = ''; + } - setSearch(value) { - this.search = value || ''; - this.renderArea(); - } + setSearch(value) { + this.search = value || ''; + this.renderArea(); + } - async initSynth() { - if (!this.synth) this.synth = await LoopUtils.createSynth(); - } + async initSynth() { + if (!this.synth) this.synth = await LoopUtils.createSynth(); + } - renderTabHtml() { - const t = this.modal.t.bind(this.modal); - return ` -
+ renderTabHtml() { + const t = this.modal.t.bind(this.modal); + return ` +
@@ -60,49 +60,52 @@
${t('loopCreator.libraryEmpty')}
`; - } + } - renderArea() { - const m = this.modal; - const area = m.$('#lm-live-area'); - if (!area) return; - if (!m.library.length) { - area.innerHTML = `
${m.t('loopCreator.libraryEmpty')}
`; - return; - } + renderArea() { + const m = this.modal; + const area = m.$('#lm-live-area'); + if (!area) return; + if (!m.library.length) { + area.innerHTML = `
${m.t('loopCreator.libraryEmpty')}
`; + return; + } - const q = this.search.trim().toLowerCase(); - const filtered = q - ? m.library.filter(l => l.name.toLowerCase().includes(q)) - : m.library; + const q = this.search.trim().toLowerCase(); + const filtered = q ? m.library.filter((l) => l.name.toLowerCase().includes(q)) : m.library; - if (!filtered.length) { - area.innerHTML = `
${m.t('loopCreator.libraryEmpty')}
`; - return; - } + if (!filtered.length) { + area.innerHTML = `
${m.t('loopCreator.libraryEmpty')}
`; + return; + } - // Group loops by GM family - const groups = new Map(); // familyName → { family, loops[] } - for (const loop of filtered) { - const family = LoopUtils.familyForProgram(loop.instrument_program ?? 0); - if (!groups.has(family.name)) groups.set(family.name, { family, loops: [] }); - groups.get(family.name).loops.push(loop); - } + // Group loops by GM family + const groups = new Map(); // familyName → { family, loops[] } + for (const loop of filtered) { + const family = LoopUtils.familyForProgram(loop.instrument_program ?? 0); + if (!groups.has(family.name)) groups.set(family.name, { family, loops: [] }); + groups.get(family.name).loops.push(loop); + } - area.innerHTML = [...groups.values()].map(({ family, loops }) => ` + area.innerHTML = [...groups.values()] + .map( + ({ family, loops }) => `
${m._instrIconHtml(family.start, 'family', 'lm-live-group-icon')} ${family.name}
- ${loops.map(l => { - const playing = this.playingLoops.has(l.id); - const tempoRange = l.tempo < 90 ? 'slow' : l.tempo < 140 ? 'medium' : 'fast'; + ${loops + .map((l) => { + const playing = this.playingLoops.has(l.id); + const tempoRange = + l.tempo < 90 ? 'slow' : l.tempo < 140 ? 'medium' : 'fast'; // AUDIT §A9 : redonde l'info tempo en texte (le bord // coloré seul violait WCAG 1.4.1 — color-only). // aria-pressed expose l'état playing aux SR. - const tempoLabel = m.t('loopManager.tempoRange_' + tempoRange) || tempoRange; + const tempoLabel = + m.t('loopManager.tempoRange_' + tempoRange) || tempoRange; return ``; - }).join('')} + }) + .join('')}
-
`).join(''); - } - - async trigger(loopId) { - const m = this.modal; - if (this.playingLoops.has(loopId)) { - this.stop(loopId); - return; - } - const loopData = await m._fetchLoopData(loopId); - if (!loopData) { - LoopUtils.toast(m.t('loopManager.errLoopUnavailable'), 'error'); - return; - } +
` + ) + .join(''); + } - // Drum kits (program ≥ 128) DOIVENT sortir sur le canal 9, sinon - // MidiSynthesizer.playNote() prend le path mélodique et la loop - // reste silencieuse. Les autres loops se partagent les canaux - // libres via _allocChannel(). - const prog = loopData.instrument_program ?? 0; - const isDrum = prog >= 128; - const ch = isDrum ? 9 : this._allocChannel(); - const target = m._getOutputTarget(this.synth); - if (target) { - try { target.setChannelInstrument(ch, prog); } - catch (err) { LoopUtils.handleError(err, 'live.synth.setChannelInstrument'); } - if (isDrum) { - await target.loadDrumKit?.().catch(err => - LoopUtils.handleError(err, 'live.synth.loadDrumKit')); - } else if (!target.loadedInstruments?.has(prog)) { - await target.loadInstrument(prog).catch(err => - LoopUtils.handleError(err, 'live.synth.loadInstrument')); - } - } + async trigger(loopId) { + const m = this.modal; + if (this.playingLoops.has(loopId)) { + this.stop(loopId); + return; + } + const loopData = await m._fetchLoopData(loopId); + if (!loopData) { + LoopUtils.toast(m.t('loopManager.errLoopUnavailable'), 'error'); + return; + } - const loopDurMs = LoopUtils.loopDurationMs(loopData); - this.playingLoops.set(loopId, { timers: [], ch, startMs: performance.now(), durMs: loopDurMs }); - this._updateButton(loopId, true); - this._scheduleLoop(loopId, loopData); - m._renderPlaybar(); + // Drum kits (program ≥ 128) DOIVENT sortir sur le canal 9, sinon + // MidiSynthesizer.playNote() prend le path mélodique et la loop + // reste silencieuse. Les autres loops se partagent les canaux + // libres via _allocChannel(). + const prog = loopData.instrument_program ?? 0; + const isDrum = prog >= 128; + const ch = isDrum ? 9 : this._allocChannel(); + const target = m._getOutputTarget(this.synth); + if (target) { + try { + target.setChannelInstrument(ch, prog); + } catch (err) { + LoopUtils.handleError(err, 'live.synth.setChannelInstrument'); } + if (isDrum) { + await target + .loadDrumKit?.() + .catch((err) => LoopUtils.handleError(err, 'live.synth.loadDrumKit')); + } else if (!target.loadedInstruments?.has(prog)) { + await target + .loadInstrument(prog) + .catch((err) => LoopUtils.handleError(err, 'live.synth.loadInstrument')); + } + } - _scheduleLoop(loopId, loopData) { - const m = this.modal; - if (!this.playingLoops.has(loopId)) return; - const state = this.playingLoops.get(loopId); - const ch = state.ch ?? 0; + const loopDurMs = LoopUtils.loopDurationMs(loopData); + this.playingLoops.set(loopId, { + timers: [], + ch, + startMs: performance.now(), + durMs: loopDurMs + }); + this._updateButton(loopId, true); + this._scheduleLoop(loopId, loopData); + m._renderPlaybar(); + } - // Cache the parsed sequence on loopData, keyed on the raw - // midi_data source. _scheduleLoop recurses every cycle via - // onCycleEnd, so without this a 4-bar loop re-runs JSON.parse - // every ~8s for its whole lifetime. The source key auto- - // invalidates the cache if the loop is edited mid-playback. - let seq; - if (loopData._seqCache && loopData._seqCacheSrc === loopData.midi_data) { - seq = loopData._seqCache; - } else { - seq = LoopUtils.parseSequence(loopData.midi_data); - loopData._seqCache = seq; - loopData._seqCacheSrc = loopData.midi_data; - } - const loopDurMs = LoopUtils.loopDurationMs(loopData); + _scheduleLoop(loopId, loopData) { + const m = this.modal; + if (!this.playingLoops.has(loopId)) return; + const state = this.playingLoops.get(loopId); + const ch = state.ch ?? 0; - // Reset cycle start time and clear old timers - state.timers.forEach(t => clearTimeout(t)); - state.startMs = performance.now(); - state.durMs = loopDurMs; + // Cache the parsed sequence on loopData, keyed on the raw + // midi_data source. _scheduleLoop recurses every cycle via + // onCycleEnd, so without this a 4-bar loop re-runs JSON.parse + // every ~8s for its whole lifetime. The source key auto- + // invalidates the cache if the loop is edited mid-playback. + let seq; + if (loopData._seqCache && loopData._seqCacheSrc === loopData.midi_data) { + seq = loopData._seqCache; + } else { + seq = LoopUtils.parseSequence(loopData.midi_data); + loopData._seqCache = seq; + loopData._seqCacheSrc = loopData.midi_data; + } + const loopDurMs = LoopUtils.loopDurationMs(loopData); - const isAlive = () => this.playingLoops.has(loopId); - state.timers = LoopUtils.scheduleSequence({ - synth: m._getOutputTarget(this.synth), - sequence: seq, - tempo: loopData.tempo || 120, - ppq: loopData.ppq || 480, - channel: ch, - isAlive, - cycleMs: loopDurMs, - onCycleEnd: () => { if (isAlive()) this._scheduleLoop(loopId, loopData); } - }); - } + // Reset cycle start time and clear old timers + state.timers.forEach((t) => clearTimeout(t)); + state.startMs = performance.now(); + state.durMs = loopDurMs; - stop(loopId) { - const m = this.modal; - const state = this.playingLoops.get(loopId); - if (!state) return; - state.timers.forEach(t => clearTimeout(t)); - this.playingLoops.delete(loopId); - // Coupe les notes encore tenues sur le canal du loop. Sans ça, - // les note-on déjà émis continuent à sonner jusqu'à leur fin - // naturelle ; l'utilisateur perçoit un release au lieu d'un - // silence net (AUDIT §L7). - const target = m._getOutputTarget(this.synth); - if (target && state.ch != null) { - try { target.allNotesOff?.(state.ch); } - catch (err) { LoopUtils.handleError(err, 'live.allNotesOff'); } - // Fallback synthé : si allNotesOff par canal n'existe pas, - // cancelAllNotes coupe tout sur ce target. - if (typeof target.allNotesOff !== 'function') { - try { target.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'live.cancelAllNotes'); } - } - } - this._updateButton(loopId, false); - m._renderPlaybar(); + const isAlive = () => this.playingLoops.has(loopId); + state.timers = LoopUtils.scheduleSequence({ + synth: m._getOutputTarget(this.synth), + sequence: seq, + tempo: loopData.tempo || 120, + ppq: loopData.ppq || 480, + channel: ch, + isAlive, + cycleMs: loopDurMs, + onCycleEnd: () => { + if (isAlive()) this._scheduleLoop(loopId, loopData); } + }); + } - stopAll() { - const m = this.modal; - for (const loopId of [...this.playingLoops.keys()]) this.stop(loopId); - try { this.synth?.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'live.synth.cancelAllNotes'); } - try { m._deviceShim?.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'live.device.cancelAllNotes'); } + stop(loopId) { + const m = this.modal; + const state = this.playingLoops.get(loopId); + if (!state) return; + state.timers.forEach((t) => clearTimeout(t)); + this.playingLoops.delete(loopId); + // Coupe les notes encore tenues sur le canal du loop. Sans ça, + // les note-on déjà émis continuent à sonner jusqu'à leur fin + // naturelle ; l'utilisateur perçoit un release au lieu d'un + // silence net (AUDIT §L7). + const target = m._getOutputTarget(this.synth); + if (target && state.ch != null) { + try { + target.allNotesOff?.(state.ch); + } catch (err) { + LoopUtils.handleError(err, 'live.allNotesOff'); } - - _updateButton(loopId, playing) { - const buttons = this.modal.$$(`[data-action="live-trigger"][data-loop-id="${loopId}"]`); - buttons.forEach(btn => { - btn.classList.toggle('lm-live-loop-btn--playing', playing); - if (btn.classList.contains('lc-card-btn--play')) { - btn.classList.toggle('lc-card-btn--playing', playing); - btn.textContent = playing ? '⏹' : '▶'; - } - }); + // Fallback synthé : si allNotesOff par canal n'existe pas, + // cancelAllNotes coupe tout sur ce target. + if (typeof target.allNotesOff !== 'function') { + try { + target.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'live.cancelAllNotes'); + } } + } + this._updateButton(loopId, false); + m._renderPlaybar(); + } - _allocChannel() { - const used = new Set([...this.playingLoops.values()].map(s => s.ch).filter(c => c != null)); - for (let c = 0; c < 16; c++) { - if (!used.has(c)) return c; - } - return 0; // all 16 channels in use: wrap around - } + stopAll() { + const m = this.modal; + for (const loopId of [...this.playingLoops.keys()]) this.stop(loopId); + try { + this.synth?.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'live.synth.cancelAllNotes'); + } + try { + m._deviceShim?.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'live.device.cancelAllNotes'); + } } - if (typeof window !== 'undefined') { - window.LoopManagerLiveFeature = LoopManagerLiveFeature; + _updateButton(loopId, playing) { + const buttons = this.modal.$$(`[data-action="live-trigger"][data-loop-id="${loopId}"]`); + buttons.forEach((btn) => { + btn.classList.toggle('lm-live-loop-btn--playing', playing); + if (btn.classList.contains('lc-card-btn--play')) { + btn.classList.toggle('lc-card-btn--playing', playing); + btn.textContent = playing ? '⏹' : '▶'; + } + }); } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopManagerLiveFeature; + + _allocChannel() { + const used = new Set( + [...this.playingLoops.values()].map((s) => s.ch).filter((c) => c != null) + ); + for (let c = 0; c < 16; c++) { + if (!used.has(c)) return c; + } + return 0; // all 16 channels in use: wrap around } + } + + if (typeof window !== 'undefined') { + window.LoopManagerLiveFeature = LoopManagerLiveFeature; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopManagerLiveFeature; + } })(); diff --git a/public/js/features/LoopManagerOutputRouter.js b/public/js/features/LoopManagerOutputRouter.js index 776db6edd..7bf8d91d3 100644 --- a/public/js/features/LoopManagerOutputRouter.js +++ b/public/js/features/LoopManagerOutputRouter.js @@ -25,128 +25,135 @@ * - panicTarget(target) */ (function () { - 'use strict'; + 'use strict'; - class LoopManagerOutputRouter { - /** @param {LoopManagerModal} modal */ - constructor(modal) { - this.modal = modal; - this.globalOutput = { mode: 'synth', deviceId: null, channel: 0 }; - this.deviceShim = null; - this.cachedDevices = []; - } + class LoopManagerOutputRouter { + /** @param {LoopManagerModal} modal */ + constructor(modal) { + this.modal = modal; + this.globalOutput = { mode: 'synth', deviceId: null, channel: 0 }; + this.deviceShim = null; + this.cachedDevices = []; + } - async loadDevices() { - // Devices are no longer picked from the header — the keyboard panel's - // instrument selector sets the global deviceId. We still cache the - // device list here so other tabs can resolve names if needed. - try { - const allDevices = await this.modal.api.listDevices(); - this.cachedDevices = (allDevices || []).filter(d => d.status === 2 || d.connected === true); - } catch (err) { - LoopUtils.handleError(err, 'manager.header.listDevices'); - this.cachedDevices = []; - } - this.refreshUI(); - } + async loadDevices() { + // Devices are no longer picked from the header — the keyboard panel's + // instrument selector sets the global deviceId. We still cache the + // device list here so other tabs can resolve names if needed. + try { + const allDevices = await this.modal.api.listDevices(); + this.cachedDevices = (allDevices || []).filter( + (d) => d.status === 2 || d.connected === true + ); + } catch (err) { + LoopUtils.handleError(err, 'manager.header.listDevices'); + this.cachedDevices = []; + } + this.refreshUI(); + } - refreshUI() { - const m = this.modal; - const btn = m.$('#lc-header-output-btn'); - const icon = m.$('#lc-header-output-icon'); - const label = m.$('#lc-header-output-label'); - const isDev = this.globalOutput.mode === 'device'; - if (icon) icon.textContent = isDev ? '🔌' : '🔊'; - if (label) label.textContent = isDev ? m.t('loopManager.outputLive') : m.t('loopManager.outputSynth'); - if (btn) { - btn.classList.toggle('lc-header-output-btn--device', isDev); - btn.setAttribute('aria-pressed', isDev ? 'true' : 'false'); - } - } + refreshUI() { + const m = this.modal; + const btn = m.$('#lc-header-output-btn'); + const icon = m.$('#lc-header-output-icon'); + const label = m.$('#lc-header-output-label'); + const isDev = this.globalOutput.mode === 'device'; + if (icon) icon.textContent = isDev ? '🔌' : '🔊'; + if (label) + label.textContent = isDev ? m.t('loopManager.outputLive') : m.t('loopManager.outputSynth'); + if (btn) { + btn.classList.toggle('lc-header-output-btn--device', isDev); + btn.setAttribute('aria-pressed', isDev ? 'true' : 'false'); + } + } - toggleMode() { - // Pure mode switch: preview-synth ⇄ live-device. If no device has - // been picked yet in the virtual piano, the per-tab routing - // gracefully falls back to the synth (see keyboard._routingDevice - // and getTarget) — no upfront check needed. - const next = this.globalOutput.mode === 'device' ? 'synth' : 'device'; - this.setOutput({ mode: next }); - } + toggleMode() { + // Pure mode switch: preview-synth ⇄ live-device. If no device has + // been picked yet in the virtual piano, the per-tab routing + // gracefully falls back to the synth (see keyboard._routingDevice + // and getTarget) — no upfront check needed. + const next = this.globalOutput.mode === 'device' ? 'synth' : 'device'; + this.setOutput({ mode: next }); + } - /** - * Merge-patch the global output. When the routing target actually - * changes, stops every playback source on the modal and panics the - * previous device to avoid stuck notes. - * @param {Object} next - Partial { mode, deviceId, channel }. - */ - setOutput(next) { - const prev = this.globalOutput; - this.globalOutput = { ...prev, ...next }; - if (prev.mode !== this.globalOutput.mode || prev.deviceId !== this.globalOutput.deviceId) { - const m = this.modal; - m._stopAllPads(); - m._liveStopAll(); - m._stopArrangerPlay(); - m.keyboard?.stopAllNotes(); - this.panicTarget(prev); - this.deviceShim = null; // rebuilt lazily - } - this.refreshUI(); - } + /** + * Merge-patch the global output. When the routing target actually + * changes, stops every playback source on the modal and panics the + * previous device to avoid stuck notes. + * @param {Object} next - Partial { mode, deviceId, channel }. + */ + setOutput(next) { + const prev = this.globalOutput; + this.globalOutput = { ...prev, ...next }; + if (prev.mode !== this.globalOutput.mode || prev.deviceId !== this.globalOutput.deviceId) { + const m = this.modal; + m._stopAllPads(); + m._liveStopAll(); + m._stopArrangerPlay(); + m.keyboard?.stopAllNotes(); + this.panicTarget(prev); + this.deviceShim = null; // rebuilt lazily + } + this.refreshUI(); + } - panicTarget(target) { - if (!target || target.mode !== 'device' || !target.deviceId) return; - this.modal.api.sendCommand('midi_panic', { deviceId: target.deviceId }) - .catch(err => LoopUtils.handleError(err, 'manager.output.panic')); - } + panicTarget(target) { + if (!target || target.mode !== 'device' || !target.deviceId) return; + this.modal.api + .sendCommand('midi_panic', { deviceId: target.deviceId }) + .catch((err) => LoopUtils.handleError(err, 'manager.output.panic')); + } - /** - * Resolve the active output: a backend device shim when in - * device mode + a deviceId is set, otherwise the supplied - * fallback synth. - * @param {Object} fallbackSynth - */ - getTarget(fallbackSynth) { - if (this.globalOutput.mode === 'device' && this.globalOutput.deviceId) { - if (!this.deviceShim) this.deviceShim = this._makeDeviceShim(); - return this.deviceShim; - } - return fallbackSynth; - } + /** + * Resolve the active output: a backend device shim when in + * device mode + a deviceId is set, otherwise the supplied + * fallback synth. + * @param {Object} fallbackSynth + */ + getTarget(fallbackSynth) { + if (this.globalOutput.mode === 'device' && this.globalOutput.deviceId) { + if (!this.deviceShim) this.deviceShim = this._makeDeviceShim(); + return this.deviceShim; + } + return fallbackSynth; + } - _makeDeviceShim() { - const api = this.modal.api; - const getOutput = () => this.globalOutput; - return { - loadedInstruments: { has: () => true }, - loadInstrument: async () => {}, - setChannelInstrument: () => {}, - cancelAllNotes: () => { - const out = getOutput(); - if (!out.deviceId) return; - api.sendCommand('midi_panic', { deviceId: out.deviceId }) - .catch(err => LoopUtils.handleError(err, 'manager.shim.panic')); - }, - playNote: (note, velocity, _ch, durSec) => { - const out = getOutput(); - if (!out.deviceId) return null; - api.sendCommand('midi_send_note', { - deviceId: out.deviceId, - channel: out.channel ?? 0, - note, - velocity: velocity || 80, - duration: Math.max(20, Math.round((durSec || 0.5) * 1000)) - }).catch(err => LoopUtils.handleError(err, 'manager.shim.playNote')); - return null; - } - }; + _makeDeviceShim() { + const api = this.modal.api; + const getOutput = () => this.globalOutput; + return { + loadedInstruments: { has: () => true }, + loadInstrument: async () => {}, + setChannelInstrument: () => {}, + cancelAllNotes: () => { + const out = getOutput(); + if (!out.deviceId) return; + api + .sendCommand('midi_panic', { deviceId: out.deviceId }) + .catch((err) => LoopUtils.handleError(err, 'manager.shim.panic')); + }, + playNote: (note, velocity, _ch, durSec) => { + const out = getOutput(); + if (!out.deviceId) return null; + api + .sendCommand('midi_send_note', { + deviceId: out.deviceId, + channel: out.channel ?? 0, + note, + velocity: velocity || 80, + duration: Math.max(20, Math.round((durSec || 0.5) * 1000)) + }) + .catch((err) => LoopUtils.handleError(err, 'manager.shim.playNote')); + return null; } + }; } + } - if (typeof window !== 'undefined') { - window.LoopManagerOutputRouter = LoopManagerOutputRouter; - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopManagerOutputRouter; - } + if (typeof window !== 'undefined') { + window.LoopManagerOutputRouter = LoopManagerOutputRouter; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopManagerOutputRouter; + } })(); diff --git a/public/js/features/LoopManagerPadFeature.js b/public/js/features/LoopManagerPadFeature.js index 999780444..aaa0b72ac 100644 --- a/public/js/features/LoopManagerPadFeature.js +++ b/public/js/features/LoopManagerPadFeature.js @@ -32,425 +32,468 @@ * - playTimes (Map, readonly) — used by _renderPlaybar */ (function () { - 'use strict'; - - class LoopManagerPadFeature { - /** @param {LoopManagerModal} modal */ - constructor(modal) { - this.modal = modal; - this.cols = 4; - this.rows = 4; - this.playMode = 'loop'; // 'loop' | 'one-shot' | 'hold' - this.quantize = 'off'; // 'off' | 'beat' | 'bar' - this.slots = Array(this.cols * this.rows).fill(null); - this.playingIndex = new Set(); - this.playbackTimers = new Map(); // padIndex → [timerIds] - this.synth = null; - this.pickerIndex = null; // pad index whose picker is open - this._pickerHandler = null; - this._clockStartMs = null; // free-running launch-quantize reference - this.holdActive = new Set(); - this.playTimes = new Map(); // padIndex → { startMs, durMs } - this._clearLongPress = null; // set on first renderGrid() - // Sub-feature: HTML template + grid renderer (audit §1.3). - this.view = typeof LoopManagerPadView !== 'undefined' - ? new LoopManagerPadView(this) - : null; - } + 'use strict'; + + class LoopManagerPadFeature { + /** @param {LoopManagerModal} modal */ + constructor(modal) { + this.modal = modal; + this.cols = 4; + this.rows = 4; + this.playMode = 'loop'; // 'loop' | 'one-shot' | 'hold' + this.quantize = 'off'; // 'off' | 'beat' | 'bar' + this.slots = Array(this.cols * this.rows).fill(null); + this.playingIndex = new Set(); + this.playbackTimers = new Map(); // padIndex → [timerIds] + this.synth = null; + this.pickerIndex = null; // pad index whose picker is open + this._pickerHandler = null; + this._clockStartMs = null; // free-running launch-quantize reference + this.holdActive = new Set(); + this.playTimes = new Map(); // padIndex → { startMs, durMs } + this._clearLongPress = null; // set on first renderGrid() + // Sub-feature: HTML template + grid renderer (audit §1.3). + this.view = typeof LoopManagerPadView !== 'undefined' ? new LoopManagerPadView(this) : null; + } - // ------------------------------------------------------------- - // Template - // ------------------------------------------------------------- - - // Delegates to view sub-feature (extracted per audit §1.3) - renderTabHtml() { return this.view?.renderTabHtml() ?? ''; } - async initSynth() { return this.view?.initSynth(); } - clearLongPress() { return this.view?.clearLongPress(); } - renderGrid() { return this.view?.renderGrid(); } - - // ------------------------------------------------------------- - // Layout config - // ------------------------------------------------------------- - - _resize(newCols, newRows) { - const cols = Math.max(1, Math.min(8, parseInt(newCols) || this.cols)); - const rows = Math.max(1, Math.min(8, parseInt(newRows) || this.rows)); - if (cols === this.cols && rows === this.rows) return; - const oldCols = this.cols; - const oldRows = this.rows; - const oldSlots = this.slots; - const next = new Array(cols * rows).fill(null); - for (let r = 0; r < Math.min(rows, oldRows); r++) { - for (let c = 0; c < Math.min(cols, oldCols); c++) { - next[r * cols + c] = oldSlots[r * oldCols + c]; - } - } - this.stopAll(); - this.cols = cols; - this.rows = rows; - this.slots = next; - this._syncControls(); - this.renderGrid(); - this._persist(); - } + // ------------------------------------------------------------- + // Template + // ------------------------------------------------------------- - setCols(v) { this._resize(v, this.rows); } - setRows(v) { this._resize(this.cols, v); } - adjustCols(d) { this._resize(this.cols + d, this.rows); } - adjustRows(d) { this._resize(this.cols, this.rows + d); } - - setPlayMode(mode) { - if (!['loop', 'one-shot', 'hold'].includes(mode)) return; - if (mode === this.playMode) return; - this.playMode = mode; - this.stopAll(); - this._persist(); - this._syncModeButtons(); - } + // Delegates to view sub-feature (extracted per audit §1.3) + renderTabHtml() { + return this.view?.renderTabHtml() ?? ''; + } + async initSynth() { + return this.view?.initSynth(); + } + clearLongPress() { + return this.view?.clearLongPress(); + } + renderGrid() { + return this.view?.renderGrid(); + } - setQuantize(quantize) { - if (!['off', 'beat', 'bar'].includes(quantize)) return; - if (quantize === this.quantize) return; - this.quantize = quantize; - if (this.playingIndex.size === 0) this._clockStartMs = null; - this._persist(); - this._syncQuantButtons(); + // ------------------------------------------------------------- + // Layout config + // ------------------------------------------------------------- + + _resize(newCols, newRows) { + const cols = Math.max(1, Math.min(8, parseInt(newCols) || this.cols)); + const rows = Math.max(1, Math.min(8, parseInt(newRows) || this.rows)); + if (cols === this.cols && rows === this.rows) return; + const oldCols = this.cols; + const oldRows = this.rows; + const oldSlots = this.slots; + const next = new Array(cols * rows).fill(null); + for (let r = 0; r < Math.min(rows, oldRows); r++) { + for (let c = 0; c < Math.min(cols, oldCols); c++) { + next[r * cols + c] = oldSlots[r * oldCols + c]; } + } + this.stopAll(); + this.cols = cols; + this.rows = rows; + this.slots = next; + this._syncControls(); + this.renderGrid(); + this._persist(); + } - _syncModeButtons() { - this.modal.$$('.lm-pad-mode-btn').forEach(btn => { - const active = btn.dataset.mode === this.playMode; - btn.classList.toggle('lm-pad-mode-btn--active', active); - btn.setAttribute('aria-pressed', active ? 'true' : 'false'); - }); - } + setCols(v) { + this._resize(v, this.rows); + } + setRows(v) { + this._resize(this.cols, v); + } + adjustCols(d) { + this._resize(this.cols + d, this.rows); + } + adjustRows(d) { + this._resize(this.cols, this.rows + d); + } - _syncQuantButtons() { - this.modal.$$('.lm-pad-quant-btn').forEach(btn => { - const active = btn.dataset.quantize === this.quantize; - btn.classList.toggle('lm-pad-quant-btn--active', active); - btn.setAttribute('aria-pressed', active ? 'true' : 'false'); - }); - } + setPlayMode(mode) { + if (!['loop', 'one-shot', 'hold'].includes(mode)) return; + if (mode === this.playMode) return; + this.playMode = mode; + this.stopAll(); + this._persist(); + this._syncModeButtons(); + } - _syncControls() { - const m = this.modal; - const colsIn = m.$('#lm-pad-cols'); if (colsIn) colsIn.value = this.cols; - const rowsIn = m.$('#lm-pad-rows'); if (rowsIn) rowsIn.value = this.rows; - this._syncModeButtons(); - this._syncQuantButtons(); - } + setQuantize(quantize) { + if (!['off', 'beat', 'bar'].includes(quantize)) return; + if (quantize === this.quantize) return; + this.quantize = quantize; + if (this.playingIndex.size === 0) this._clockStartMs = null; + this._persist(); + this._syncQuantButtons(); + } - // ------------------------------------------------------------- - // Playback - // ------------------------------------------------------------- - - async trigger(index, opts = {}) { - const m = this.modal; - const slot = this.slots[index]; - if (!slot) return; - - // In non-hold modes, a click on an already-playing pad acts as a toggle. - // In hold mode, pointerdown should restart playback if pad is already - // playing (e.g. from a previous hold not yet faded). - if (this.playingIndex.has(index)) { - if (this.playMode === 'hold' && opts.fromHold) { - this.stop(index); - } else { - this.stop(index); - return; - } - } - - const loopData = await m._fetchLoopData(slot.loopId); - if (!loopData) { - LoopUtils.toast(m.t('loopManager.errLoopUnavailable'), 'error'); - return; - } - - // Drum kits (program ≥ 128) DOIVENT sortir sur le canal 9 sinon - // MidiSynthesizer.playNote() prend le path mélodique et ne trouve - // aucun preset → loop silencieuse. Les autres pads se partagent - // les canaux 0-15 en wrappant sur l'index. - const prog = loopData.instrument_program ?? 0; - const isDrum = prog >= 128; - const ch = isDrum ? 9 : (index % 16); - const target = m._getOutputTarget(this.synth); - if (target) { - try { target.setChannelInstrument(ch, prog); } - catch (err) { LoopUtils.handleError(err, 'pad.synth.setChannelInstrument'); } - if (isDrum) { - await target.loadDrumKit?.().catch(err => - LoopUtils.handleError(err, 'pad.synth.loadDrumKit')); - } else if (!target.loadedInstruments?.has(prog)) { - await target.loadInstrument(prog).catch(err => - LoopUtils.handleError(err, 'pad.synth.loadInstrument')); - } - } - - // If hold was released between fetch and now, abort - if (this.playMode === 'hold' && opts.fromHold && !this.holdActive.has(index)) { - return; - } - - this.playingIndex.add(index); - this._updateCell(index); - this._schedule(index, loopData, ch, { quantize: true }); - } + _syncModeButtons() { + this.modal.$$('.lm-pad-mode-btn').forEach((btn) => { + const active = btn.dataset.mode === this.playMode; + btn.classList.toggle('lm-pad-mode-btn--active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } - _computeStartDelay(loopData) { - if (this.quantize === 'off') return 0; - if (this._clockStartMs == null) { - this._clockStartMs = performance.now(); - return 0; - } - const tempo = loopData.tempo || 120; - const beatMs = 60000 / tempo; - const stepMs = this.quantize === 'bar' ? beatMs * 4 : beatMs; - const elapsed = performance.now() - this._clockStartMs; - const phase = ((elapsed % stepMs) + stepMs) % stepMs; - const delay = stepMs - phase; - return delay < 5 ? 0 : delay; - } + _syncQuantButtons() { + this.modal.$$('.lm-pad-quant-btn').forEach((btn) => { + const active = btn.dataset.quantize === this.quantize; + btn.classList.toggle('lm-pad-quant-btn--active', active); + btn.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + } - _schedule(index, loopData, channel = 0, opts = {}) { - const m = this.modal; - const { quantize = false, cycleStart = false } = opts; - const seq = LoopUtils.parseSequence(loopData.midi_data); - const loopDurMs = LoopUtils.loopDurationMs(loopData); - const tempo = loopData.tempo || 120; - const ppq = loopData.ppq || 480; - const startDelay = (quantize && !cycleStart) ? this._computeStartDelay(loopData) : 0; - const isLoopMode = (this.playMode === 'loop' || this.playMode === 'hold'); - - this.playTimes.set(index, { - startMs: performance.now() + startDelay, - durMs: loopDurMs - }); - m._renderPlaybar(); - - const isAlive = () => this.playingIndex.has(index); - const timers = LoopUtils.scheduleSequence({ - synth: m._getOutputTarget(this.synth), - sequence: seq, tempo, ppq, channel, - startDelayMs: startDelay, - isAlive, - cycleMs: loopDurMs + 50, - onCycleEnd: () => { - if (!isAlive()) return; - if (isLoopMode) { - this._schedule(index, loopData, channel, { cycleStart: true }); - } else { - this.stop(index); - } - } - }); - - this.playbackTimers.set(index, timers); - } + _syncControls() { + const m = this.modal; + const colsIn = m.$('#lm-pad-cols'); + if (colsIn) colsIn.value = this.cols; + const rowsIn = m.$('#lm-pad-rows'); + if (rowsIn) rowsIn.value = this.rows; + this._syncModeButtons(); + this._syncQuantButtons(); + } - stop(index) { - const m = this.modal; - (this.playbackTimers.get(index) || []).forEach(t => clearTimeout(t)); - this.playbackTimers.delete(index); - this.playingIndex.delete(index); - this.playTimes.delete(index); - this._updateCell(index); - m._renderPlaybar(); + // ------------------------------------------------------------- + // Playback + // ------------------------------------------------------------- + + async trigger(index, opts = {}) { + const m = this.modal; + const slot = this.slots[index]; + if (!slot) return; + + // In non-hold modes, a click on an already-playing pad acts as a toggle. + // In hold mode, pointerdown should restart playback if pad is already + // playing (e.g. from a previous hold not yet faded). + if (this.playingIndex.has(index)) { + if (this.playMode === 'hold' && opts.fromHold) { + this.stop(index); + } else { + this.stop(index); + return; } - - stopAll() { - const m = this.modal; - for (let i = 0; i < this.slots.length; i++) this.stop(i); - this.holdActive.clear(); - this.playTimes.clear(); - try { this.synth?.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'pad.synth.cancelAllNotes'); } - try { m._deviceShim?.cancelAllNotes?.(); } - catch (err) { LoopUtils.handleError(err, 'pad.device.cancelAllNotes'); } + } + + const loopData = await m._fetchLoopData(slot.loopId); + if (!loopData) { + LoopUtils.toast(m.t('loopManager.errLoopUnavailable'), 'error'); + return; + } + + // Drum kits (program ≥ 128) DOIVENT sortir sur le canal 9 sinon + // MidiSynthesizer.playNote() prend le path mélodique et ne trouve + // aucun preset → loop silencieuse. Les autres pads se partagent + // les canaux 0-15 en wrappant sur l'index. + const prog = loopData.instrument_program ?? 0; + const isDrum = prog >= 128; + const ch = isDrum ? 9 : index % 16; + const target = m._getOutputTarget(this.synth); + if (target) { + try { + target.setChannelInstrument(ch, prog); + } catch (err) { + LoopUtils.handleError(err, 'pad.synth.setChannelInstrument'); } - - _updateCell(index) { - const cell = this.modal.$(`#lm-pad-grid .lm-pad-cell[data-pad-index="${index}"]`); - if (!cell) return; - const slot = this.slots[index]; - const playing = this.playingIndex.has(index); - cell.classList.toggle('lm-pad-cell--playing', playing); - cell.classList.toggle('lm-pad-cell--assigned', slot !== null); + if (isDrum) { + await target + .loadDrumKit?.() + .catch((err) => LoopUtils.handleError(err, 'pad.synth.loadDrumKit')); + } else if (!target.loadedInstruments?.has(prog)) { + await target + .loadInstrument(prog) + .catch((err) => LoopUtils.handleError(err, 'pad.synth.loadInstrument')); } + } - // ------------------------------------------------------------- - // Assignment + picker - // ------------------------------------------------------------- + // If hold was released between fetch and now, abort + if (this.playMode === 'hold' && opts.fromHold && !this.holdActive.has(index)) { + return; + } - assignSlot(index, loopId) { - const m = this.modal; - const loop = m.library.find(l => l.id === loopId); - if (!loop) return; + this.playingIndex.add(index); + this._updateCell(index); + this._schedule(index, loopData, ch, { quantize: true }); + } + + _computeStartDelay(loopData) { + if (this.quantize === 'off') return 0; + if (this._clockStartMs == null) { + this._clockStartMs = performance.now(); + return 0; + } + const tempo = loopData.tempo || 120; + const beatMs = 60000 / tempo; + const stepMs = this.quantize === 'bar' ? beatMs * 4 : beatMs; + const elapsed = performance.now() - this._clockStartMs; + const phase = ((elapsed % stepMs) + stepMs) % stepMs; + const delay = stepMs - phase; + return delay < 5 ? 0 : delay; + } + + _schedule(index, loopData, channel = 0, opts = {}) { + const m = this.modal; + const { quantize = false, cycleStart = false } = opts; + const seq = LoopUtils.parseSequence(loopData.midi_data); + const loopDurMs = LoopUtils.loopDurationMs(loopData); + const tempo = loopData.tempo || 120; + const ppq = loopData.ppq || 480; + const startDelay = quantize && !cycleStart ? this._computeStartDelay(loopData) : 0; + const isLoopMode = this.playMode === 'loop' || this.playMode === 'hold'; + + this.playTimes.set(index, { + startMs: performance.now() + startDelay, + durMs: loopDurMs + }); + m._renderPlaybar(); + + const isAlive = () => this.playingIndex.has(index); + const timers = LoopUtils.scheduleSequence({ + synth: m._getOutputTarget(this.synth), + sequence: seq, + tempo, + ppq, + channel, + startDelayMs: startDelay, + isAlive, + cycleMs: loopDurMs + 50, + onCycleEnd: () => { + if (!isAlive()) return; + if (isLoopMode) { + this._schedule(index, loopData, channel, { cycleStart: true }); + } else { this.stop(index); - this.slots[index] = { - loopId: loop.id, name: loop.name, - tempo: loop.tempo, bars: loop.bars, - instrument_program: loop.instrument_program ?? 0 - }; - this.renderGrid(); - this.closePicker(); - this._persist(); - if (m.activeTab === 'library') m.libraryFeature?.filterAndRender(); + } } + }); + + this.playbackTimers.set(index, timers); + } + + stop(index) { + const m = this.modal; + (this.playbackTimers.get(index) || []).forEach((t) => clearTimeout(t)); + this.playbackTimers.delete(index); + this.playingIndex.delete(index); + this.playTimes.delete(index); + this._updateCell(index); + m._renderPlaybar(); + } + + stopAll() { + const m = this.modal; + for (let i = 0; i < this.slots.length; i++) this.stop(i); + this.holdActive.clear(); + this.playTimes.clear(); + try { + this.synth?.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'pad.synth.cancelAllNotes'); + } + try { + m._deviceShim?.cancelAllNotes?.(); + } catch (err) { + LoopUtils.handleError(err, 'pad.device.cancelAllNotes'); + } + } + + _updateCell(index) { + const cell = this.modal.$(`#lm-pad-grid .lm-pad-cell[data-pad-index="${index}"]`); + if (!cell) return; + const slot = this.slots[index]; + const playing = this.playingIndex.has(index); + cell.classList.toggle('lm-pad-cell--playing', playing); + cell.classList.toggle('lm-pad-cell--assigned', slot !== null); + } + + // ------------------------------------------------------------- + // Assignment + picker + // ------------------------------------------------------------- + + assignSlot(index, loopId) { + const m = this.modal; + const loop = m.library.find((l) => l.id === loopId); + if (!loop) return; + this.stop(index); + this.slots[index] = { + loopId: loop.id, + name: loop.name, + tempo: loop.tempo, + bars: loop.bars, + instrument_program: loop.instrument_program ?? 0 + }; + this.renderGrid(); + this.closePicker(); + this._persist(); + if (m.activeTab === 'library') m.libraryFeature?.filterAndRender(); + } - openPicker(index, anchorEl) { - const m = this.modal; - // Reset any previous picker handler - this._detachPickerHandler(); - this.pickerIndex = index; - const picker = m.$('#lm-pad-picker'); - if (!picker) return; + openPicker(index, anchorEl) { + const m = this.modal; + // Reset any previous picker handler + this._detachPickerHandler(); + this.pickerIndex = index; + const picker = m.$('#lm-pad-picker'); + if (!picker) return; - picker.innerHTML = ` + picker.innerHTML = `
${m.t('loopManager.assignPad')}
- ${m.library.map(l => ` + ${m.library + .map( + (l) => `
${m.escape(l.name)} ${l.tempo}♩·${l.bars}M -
`).join('')} +
` + ) + .join('')}
${m.t('loopManager.clearPad')}
`; - const search = picker.querySelector('#lm-picker-search'); - if (search) { - search.addEventListener('input', () => { - const q = search.value.trim().toLowerCase(); - picker.querySelectorAll('.lm-picker-item[data-search-name]').forEach(el => { - el.style.display = (!q || el.dataset.searchName.includes(q)) ? '' : 'none'; - }); - }); - requestAnimationFrame(() => search.focus()); - } - - this._pickerHandler = (e) => { - const item = e.target.closest('[data-assign-loop]'); - if (!item) return; - const val = item.dataset.assignLoop; - if (val === '__clear__') { - this.stop(index); - this.slots[index] = null; - this.renderGrid(); - this.closePicker(); - this._persist(); - } else { - this.assignSlot(index, parseInt(val)); - } - }; - picker.addEventListener('click', this._pickerHandler); - - // Position near anchor (fixed so it escapes any overflow:hidden containers) - const rect = anchorEl.getBoundingClientRect(); - picker.style.left = rect.left + 'px'; - picker.style.top = (rect.bottom + 4) + 'px'; - picker.style.display = 'block'; - } - - _detachPickerHandler() { - if (this._pickerHandler) { - const picker = this.modal.$('#lm-pad-picker'); - picker?.removeEventListener('click', this._pickerHandler); - this._pickerHandler = null; - } + const search = picker.querySelector('#lm-picker-search'); + if (search) { + search.addEventListener('input', () => { + const q = search.value.trim().toLowerCase(); + picker.querySelectorAll('.lm-picker-item[data-search-name]').forEach((el) => { + el.style.display = !q || el.dataset.searchName.includes(q) ? '' : 'none'; + }); + }); + requestAnimationFrame(() => search.focus()); + } + + this._pickerHandler = (e) => { + const item = e.target.closest('[data-assign-loop]'); + if (!item) return; + const val = item.dataset.assignLoop; + if (val === '__clear__') { + this.stop(index); + this.slots[index] = null; + this.renderGrid(); + this.closePicker(); + this._persist(); + } else { + this.assignSlot(index, parseInt(val)); } + }; + picker.addEventListener('click', this._pickerHandler); + + // Position near anchor (fixed so it escapes any overflow:hidden containers) + const rect = anchorEl.getBoundingClientRect(); + picker.style.left = rect.left + 'px'; + picker.style.top = rect.bottom + 4 + 'px'; + picker.style.display = 'block'; + } - closePicker() { - this._detachPickerHandler(); - this.pickerIndex = null; - const picker = this.modal.$('#lm-pad-picker'); - if (picker) picker.style.display = 'none'; - } + _detachPickerHandler() { + if (this._pickerHandler) { + const picker = this.modal.$('#lm-pad-picker'); + picker?.removeEventListener('click', this._pickerHandler); + this._pickerHandler = null; + } + } - // ------------------------------------------------------------- - // Persistence - // ------------------------------------------------------------- - - _persist() { - LoopUtils.PadStorage.save({ - slots: this.slots, - cols: this.cols, - rows: this.rows, - playMode: this.playMode, - quantize: this.quantize - }); - } + closePicker() { + this._detachPickerHandler(); + this.pickerIndex = null; + const picker = this.modal.$('#lm-pad-picker'); + if (picker) picker.style.display = 'none'; + } - load() { - const saved = LoopUtils.PadStorage.load(); - if (!saved) return; - const clamp = (v, min, max, fb) => { - const n = parseInt(v); - if (!Number.isFinite(n)) return fb; - return Math.max(min, Math.min(max, n)); - }; - const cols = clamp(saved.cols, 1, 8, this.cols); - const rows = clamp(saved.rows, 1, 8, this.rows); - const expected = cols * rows; - if (Array.isArray(saved.slots) && saved.slots.length === expected) { - this.cols = cols; - this.rows = rows; - this.slots = saved.slots.map(s => s ? { - loopId: s.loopId, name: s.name, - tempo: s.tempo, bars: s.bars, - instrument_program: s.instrument_program ?? 0 - } : null); - } - if (['loop', 'one-shot', 'hold'].includes(saved.playMode)) this.playMode = saved.playMode; - if (['off', 'beat', 'bar'].includes(saved.quantize)) this.quantize = saved.quantize; - } + // ------------------------------------------------------------- + // Persistence + // ------------------------------------------------------------- + + _persist() { + LoopUtils.PadStorage.save({ + slots: this.slots, + cols: this.cols, + rows: this.rows, + playMode: this.playMode, + quantize: this.quantize + }); + } - // ------------------------------------------------------------- - // Public helpers for cross-feature coordination - // ------------------------------------------------------------- - - /** - * Clear and stop every pad slot that references the given loopId. - * Called from the modal's _deleteLoopById cascade. - * @returns {boolean} True when at least one slot was cleared. - */ - cleanupSlotsForLoop(loopId) { - let changed = false; - for (let i = 0; i < this.slots.length; i++) { - if (this.slots[i]?.loopId === loopId) { - this.stop(i); - this.slots[i] = null; - changed = true; - } - } - if (changed) this._persist(); - return changed; - } + load() { + const saved = LoopUtils.PadStorage.load(); + if (!saved) return; + const clamp = (v, min, max, fb) => { + const n = parseInt(v); + if (!Number.isFinite(n)) return fb; + return Math.max(min, Math.min(max, n)); + }; + const cols = clamp(saved.cols, 1, 8, this.cols); + const rows = clamp(saved.rows, 1, 8, this.rows); + const expected = cols * rows; + if (Array.isArray(saved.slots) && saved.slots.length === expected) { + this.cols = cols; + this.rows = rows; + this.slots = saved.slots.map((s) => + s + ? { + loopId: s.loopId, + name: s.name, + tempo: s.tempo, + bars: s.bars, + instrument_program: s.instrument_program ?? 0 + } + : null + ); + } + if (['loop', 'one-shot', 'hold'].includes(saved.playMode)) this.playMode = saved.playMode; + if (['off', 'beat', 'bar'].includes(saved.quantize)) this.quantize = saved.quantize; + } - async clearAll() { - const m = this.modal; - const ok = await LoopUtils.confirm(m.t('loopManager.confirmClearAllPads'), { - icon: '🧹', danger: true - }); - if (!ok) return; - for (let i = 0; i < this.slots.length; i++) { - this.stop(i); - this.slots[i] = null; - } - this.renderGrid(); - this._persist(); - LoopUtils.toast(m.t('loopManager.padsCleared'), 'success'); + // ------------------------------------------------------------- + // Public helpers for cross-feature coordination + // ------------------------------------------------------------- + + /** + * Clear and stop every pad slot that references the given loopId. + * Called from the modal's _deleteLoopById cascade. + * @returns {boolean} True when at least one slot was cleared. + */ + cleanupSlotsForLoop(loopId) { + let changed = false; + for (let i = 0; i < this.slots.length; i++) { + if (this.slots[i]?.loopId === loopId) { + this.stop(i); + this.slots[i] = null; + changed = true; } + } + if (changed) this._persist(); + return changed; } - if (typeof window !== 'undefined') { - window.LoopManagerPadFeature = LoopManagerPadFeature; - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = LoopManagerPadFeature; + async clearAll() { + const m = this.modal; + const ok = await LoopUtils.confirm(m.t('loopManager.confirmClearAllPads'), { + icon: '🧹', + danger: true + }); + if (!ok) return; + for (let i = 0; i < this.slots.length; i++) { + this.stop(i); + this.slots[i] = null; + } + this.renderGrid(); + this._persist(); + LoopUtils.toast(m.t('loopManager.padsCleared'), 'success'); } + } + + if (typeof window !== 'undefined') { + window.LoopManagerPadFeature = LoopManagerPadFeature; + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = LoopManagerPadFeature; + } })(); diff --git a/public/js/features/LoopManagerPadView.js b/public/js/features/LoopManagerPadView.js index 971bcdb78..62725930d 100644 --- a/public/js/features/LoopManagerPadView.js +++ b/public/js/features/LoopManagerPadView.js @@ -17,29 +17,29 @@ // ============================================================================ (function () { - 'use strict'; - - class LoopManagerPadView { - /** @param {LoopManagerPadFeature} parent */ - constructor(parent) { - this.parent = parent; - } - - renderTabHtml() { - const m = this.parent.modal; - const t = m.t.bind(m); - const modeBtn = (val, labelKey, icon) => - ``; - const quantBtn = (val, labelKey) => - ``; - return ` -
+ return ` +
@@ -58,15 +58,15 @@
${t('loopManager.padPlayMode')}
- ${modeBtn('loop', 'padModeLoop', '🔁')} + ${modeBtn('loop', 'padModeLoop', '🔁')} ${modeBtn('one-shot', 'padModeOneShot', '▶')} - ${modeBtn('hold', 'padModeHold', '✋')} + ${modeBtn('hold', 'padModeHold', '✋')}
${t('loopManager.padQuantize')}
- ${quantBtn('off', 'padQuantizeOff')} + ${quantBtn('off', 'padQuantizeOff')} ${quantBtn('beat', 'padQuantizeBeat')} - ${quantBtn('bar', 'padQuantizeBar')} + ${quantBtn('bar', 'padQuantizeBar')}
`; - } - - // ------------------------------------------------------------- - // Synth - // ------------------------------------------------------------- - - async initSynth() { - if (!this.parent.synth) this.parent.synth = await LoopUtils.createSynth(); - } - - clearLongPress() { - if (this.parent._clearLongPress) this.parent._clearLongPress(); - } - - // ------------------------------------------------------------- - // Grid rendering + pointer wiring - // ------------------------------------------------------------- - - renderGrid() { - const m = this.parent.modal; - const grid = m.$('#lm-pad-grid'); - if (!grid) return; - grid.style.setProperty('--pad-cols', this.parent.cols); - grid.style.setProperty('--pad-rows', this.parent.rows); - grid.innerHTML = this.parent.slots.map((slot, i) => { - const playing = this.parent.playingIndex.has(i); - const assigned = slot !== null; - const row = Math.floor(i / this.parent.cols); - let iconHtml = '', familyColor = ''; - if (assigned) { - const family = LoopUtils.familyForProgram(slot.instrument_program ?? 0); - iconHtml = m._instrIconHtml(slot.instrument_program ?? 0, 'instrument', 'lm-pad-icon'); - familyColor = family.color; - } - const styleAttr = familyColor ? `style="--family-color:${familyColor}"` : ''; - return `
{ + const playing = this.parent.playingIndex.has(i); + const assigned = slot !== null; + const row = Math.floor(i / this.parent.cols); + let iconHtml = '', + familyColor = ''; + if (assigned) { + const family = LoopUtils.familyForProgram(slot.instrument_program ?? 0); + iconHtml = m._instrIconHtml(slot.instrument_program ?? 0, 'instrument', 'lm-pad-icon'); + familyColor = family.color; + } + const styleAttr = familyColor ? `style="--family-color:${familyColor}"` : ''; + return `
${assigned ? m.escape(slot.name) : '+'} ${assigned ? `${slot.tempo}♩·${slot.bars}M` : ''}
`; - }).join(''); - - if (!grid.dataset.lmPadWired) { - grid.dataset.lmPadWired = '1'; - // Pointer-based events: support hold mode + long-press for assignment - const LONG_PRESS_MS = 500; - const LONG_PRESS_MOVE_PX = 8; - let lpTimer = null; - let lpStartXY = null; - let lpCell = null; - let lpFired = false; - - const clearLongPress = () => { - if (lpTimer) { clearTimeout(lpTimer); lpTimer = null; } - lpStartXY = null; lpCell = null; - }; - // Exposé pour onClose : sans ça, un timer armé < 500 ms tire - // sur un DOM détaché après fermeture de la modale (AUDIT §L9). - this.parent._clearLongPress = clearLongPress; - - grid.addEventListener('pointerdown', (e) => { - if (e.button !== 0 && e.pointerType === 'mouse') return; // left-button or touch/pen only - lpFired = false; clearLongPress(); - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (!cell) return; - const idx = parseInt(cell.dataset.padIndex); - const slot = this.parent.slots[idx]; - - // Empty pad → open picker immediately (no playback to trigger) - if (!slot) { - this.parent.openPicker(idx, cell); - return; - } - - // Assigned pad: arm long-press for re-assignment (touch-friendly - // alternative to right-click). Skipped in hold mode since hold - // already owns the press gesture. - if (this.parent.playMode !== 'hold') { - lpCell = cell; - lpStartXY = { x: e.clientX, y: e.clientY }; - lpTimer = setTimeout(() => { - lpFired = true; - lpTimer = null; - this.parent.openPicker(idx, cell); - }, LONG_PRESS_MS); - } - - if (this.parent.playMode === 'hold') { - this.parent.holdActive.add(idx); - try { cell.setPointerCapture?.(e.pointerId); } catch (_) {} - this.parent.trigger(idx, { fromHold: true }); - } - }); - - grid.addEventListener('pointermove', (e) => { - if (!lpTimer || !lpStartXY) return; - const dx = e.clientX - lpStartXY.x; - const dy = e.clientY - lpStartXY.y; - if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) clearLongPress(); - }); - - grid.addEventListener('pointerup', (e) => { - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (!cell) { clearLongPress(); return; } - const idx = parseInt(cell.dataset.padIndex); - - if (this.parent.playMode === 'hold' && this.parent.holdActive.has(idx)) { - this.parent.holdActive.delete(idx); - this.parent.stop(idx); - return; - } - - if (lpFired && lpCell === cell) { - lpFired = false; - clearLongPress(); - return; - } - - clearLongPress(); - if (this.parent.slots[idx]) this.parent.trigger(idx); - }); - - const onCancel = (e) => { - clearLongPress(); - if (this.parent.playMode !== 'hold') return; - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (!cell) return; - const idx = parseInt(cell.dataset.padIndex); - if (this.parent.holdActive.has(idx)) { - this.parent.holdActive.delete(idx); - this.parent.stop(idx); - } - }; - grid.addEventListener('pointercancel', onCancel); - grid.addEventListener('pointerleave', onCancel); - grid.addEventListener('dragover', (e) => { - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (!cell) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - cell.classList.add('lm-pad-cell--drop-target'); - }); - grid.addEventListener('dragleave', (e) => { - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (cell) cell.classList.remove('lm-pad-cell--drop-target'); - }); - grid.addEventListener('drop', (e) => { - const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); - if (!cell) return; - e.preventDefault(); - cell.classList.remove('lm-pad-cell--drop-target'); - try { - const data = JSON.parse(e.dataTransfer.getData('text/plain') || '{}'); - if (data.loopId) this.parent.assignSlot(parseInt(cell.dataset.padIndex), data.loopId); - } catch (err) { - LoopUtils.handleError(err, 'pad.drop.parse'); - } - }); - } - } - - // ------------------------------------------------------------- - // Layout config - // ------------------------------------------------------------- + }) + .join(''); - } + if (!grid.dataset.lmPadWired) { + grid.dataset.lmPadWired = '1'; + // Pointer-based events: support hold mode + long-press for assignment + const LONG_PRESS_MS = 500; + const LONG_PRESS_MOVE_PX = 8; + let lpTimer = null; + let lpStartXY = null; + let lpCell = null; + let lpFired = false; - if (typeof window !== 'undefined') { - window.LoopManagerPadView = LoopManagerPadView; + const clearLongPress = () => { + if (lpTimer) { + clearTimeout(lpTimer); + lpTimer = null; + } + lpStartXY = null; + lpCell = null; + }; + // Exposé pour onClose : sans ça, un timer armé < 500 ms tire + // sur un DOM détaché après fermeture de la modale (AUDIT §L9). + this.parent._clearLongPress = clearLongPress; + + grid.addEventListener('pointerdown', (e) => { + if (e.button !== 0 && e.pointerType === 'mouse') return; // left-button or touch/pen only + lpFired = false; + clearLongPress(); + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (!cell) return; + const idx = parseInt(cell.dataset.padIndex); + const slot = this.parent.slots[idx]; + + // Empty pad → open picker immediately (no playback to trigger) + if (!slot) { + this.parent.openPicker(idx, cell); + return; + } + + // Assigned pad: arm long-press for re-assignment (touch-friendly + // alternative to right-click). Skipped in hold mode since hold + // already owns the press gesture. + if (this.parent.playMode !== 'hold') { + lpCell = cell; + lpStartXY = { x: e.clientX, y: e.clientY }; + lpTimer = setTimeout(() => { + lpFired = true; + lpTimer = null; + this.parent.openPicker(idx, cell); + }, LONG_PRESS_MS); + } + + if (this.parent.playMode === 'hold') { + this.parent.holdActive.add(idx); + try { + cell.setPointerCapture?.(e.pointerId); + } catch (_) {} + this.parent.trigger(idx, { fromHold: true }); + } + }); + + grid.addEventListener('pointermove', (e) => { + if (!lpTimer || !lpStartXY) return; + const dx = e.clientX - lpStartXY.x; + const dy = e.clientY - lpStartXY.y; + if (dx * dx + dy * dy > LONG_PRESS_MOVE_PX * LONG_PRESS_MOVE_PX) clearLongPress(); + }); + + grid.addEventListener('pointerup', (e) => { + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (!cell) { + clearLongPress(); + return; + } + const idx = parseInt(cell.dataset.padIndex); + + if (this.parent.playMode === 'hold' && this.parent.holdActive.has(idx)) { + this.parent.holdActive.delete(idx); + this.parent.stop(idx); + return; + } + + if (lpFired && lpCell === cell) { + lpFired = false; + clearLongPress(); + return; + } + + clearLongPress(); + if (this.parent.slots[idx]) this.parent.trigger(idx); + }); + + const onCancel = (e) => { + clearLongPress(); + if (this.parent.playMode !== 'hold') return; + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (!cell) return; + const idx = parseInt(cell.dataset.padIndex); + if (this.parent.holdActive.has(idx)) { + this.parent.holdActive.delete(idx); + this.parent.stop(idx); + } + }; + grid.addEventListener('pointercancel', onCancel); + grid.addEventListener('pointerleave', onCancel); + grid.addEventListener('dragover', (e) => { + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (!cell) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + cell.classList.add('lm-pad-cell--drop-target'); + }); + grid.addEventListener('dragleave', (e) => { + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (cell) cell.classList.remove('lm-pad-cell--drop-target'); + }); + grid.addEventListener('drop', (e) => { + const cell = e.target.closest('.lm-pad-cell[data-pad-index]'); + if (!cell) return; + e.preventDefault(); + cell.classList.remove('lm-pad-cell--drop-target'); + try { + const data = JSON.parse(e.dataTransfer.getData('text/plain') || '{}'); + if (data.loopId) this.parent.assignSlot(parseInt(cell.dataset.padIndex), data.loopId); + } catch (err) { + LoopUtils.handleError(err, 'pad.drop.parse'); + } + }); + } } + + // ------------------------------------------------------------- + // Layout config + // ------------------------------------------------------------- + } + + if (typeof window !== 'undefined') { + window.LoopManagerPadView = LoopManagerPadView; + } })(); diff --git a/public/js/features/MidiEditorModal.js b/public/js/features/MidiEditorModal.js index 7e6517b52..dca5c468f 100644 --- a/public/js/features/MidiEditorModal.js +++ b/public/js/features/MidiEditorModal.js @@ -5,566 +5,668 @@ // ============================================================================ class MidiEditorModal { - /** - * @param {*} eventBus - * @param {*} apiClient - * @param {Object} [opts] - * @param {boolean} [opts.loopMode=false] Slim layout for the loop editor : - * no file/save header, no instrument/channel/GM selectors, no gear popover, - * mono-channel CC section, inline touch-mode toggle. The host (e.g. - * LoopEditorModal) drives transport/save/instrument from its own shell. - * @param {HTMLElement} [opts.host] When provided, render() injects the - * editor body into this element instead of appending a modal-overlay - * to document.body. Required for panel embedding (mountAsPanel). - */ - constructor(eventBus, apiClient, opts = {}) { - this.eventBus = eventBus; - this.api = apiClient; - this.logger = window.logger || console; - - // Panel/loop integration flags - this.loopMode = opts.loopMode === true; - this.panelHost = opts.host || null; - - this.container = null; - this.isOpen = false; - this.pianoRoll = null; - - // i18n support - this.localeUnsubscribe = null; - - // State - this.currentFile = null; // fileId - this.currentFilename = null; // filename used for display - this.midiData = null; - this.isDirty = false; - - // Note sequence for webaudio-pianoroll - this.sequence = []; - this.fullSequence = []; // All notes (all channels) - this.activeChannels = new Set(); // Active channels to display - this.channels = []; // Information about available channels - - // Clipboard for copy/paste - this.clipboard = []; - - // Current edit mode - this.editMode = 'drag-view'; // 'select', 'drag-notes', 'drag-view', 'edit' - drag-view by default for navigation - - // Touch mode: shows separate Move/Add/Resize buttons instead of the unified pencil button - this.touchMode = this._loadTouchModePref(); - - // Playback feedback preferences - this.keyboardPlaybackEnabled = this._loadKeyboardPlaybackPref(); - this.dragPlaybackEnabled = this._loadDragPlaybackPref(); - - // Instrument selected for new channels (GM MIDI program) - this.selectedInstrument = 0; // Piano by default - - // CC/Pitchbend/Velocity/Tempo Editor - this.ccEditor = null; - this.velocityEditor = null; - this.tempoEditor = null; - this.currentCCType = 'cc1'; // 'cc1', 'cc2', 'cc5', 'cc7', 'cc10', 'cc11', 'cc74', 'cc76', 'cc77', 'cc78', 'cc91', 'pitchbend', 'velocity', 'tempo' - this.ccEvents = []; // CC and pitchbend events - this.tempoEvents = []; // Tempo events - this.ccSectionExpanded = false; // Collapse state of the CC section - - // Connected instrument used for routing - this.connectedDevices = []; // List of connected MIDI devices - - // Per-channel routing: Map (e.g. "deviceId" or "deviceId::channel") - this.channelRouting = new Map(); - // Per-channel disabled state: Set - this.channelDisabled = new Set(); - // Currently open channel settings popover channel (-1 = none) - this._channelSettingsOpen = -1; - // Per-channel playable notes highlights: Map> - this.channelPlayableHighlights = new Map(); - // Cache of routed instrument gm_program per channel: Map - this._routedGmPrograms = new Map(); - // Cache of routed instrument per-instrument custom SF2 id per channel: - // Map (absent/null = use the global sound bank). - this._routedSf2Ids = new Map(); - // Preview source: 'gm' (original MIDI file instruments) or 'routed' (routed instrument gm_program) - this.previewSource = 'gm'; - // Per-channel playable note sets for routed preview: Map|null> - this._routedPlayableNotes = new Map(); - // Global toggle: auto-show playable notes for all routed channels - this.showPlayableNotes = false; - - // Channel panel (manages tablature buttons, device selector, instrument selector) - this.channelPanel = typeof MidiEditorChannelPanel !== 'undefined' ? new MidiEditorChannelPanel(this) : null; - - // Channel state read facade (audit §4.2). State stays on `this.X` - // for now; consumers can read via `this.channelState.X(...)` and - // we'll route writes through it incrementally. - this.channelState = typeof MidiEditorChannelState !== 'undefined' ? new MidiEditorChannelState(this) : null; - - // Confirmation dialogs sub-component (P2-F.10a — replaces mixin). - // Instantiated before the mixin loop so the mixin forwarders find it. - this.dialogs = typeof MidiEditorDialogs !== 'undefined' ? new MidiEditorDialogs(this) : null; - - // Draw settings popover sub-component (P2-F.10b). - this.drawSettings = typeof MidiEditorDrawSettings !== 'undefined' ? new MidiEditorDrawSettings(this) : null; - - // CC picker sub-component (P2-F.10c). All 22 methods now live on - // the class itself — callsites use `modal.ccPicker.(...)`. - // The mixin has been removed from the prototype. - this.ccPicker = typeof MidiEditorCCPicker !== 'undefined' ? new MidiEditorCCPicker(this) : null; - - // Remaining 9 facades (P2-F.10-wire). Thin auto-generated facades - // around the legacy mixins ; callsites migrate progressively from - // `this.()` to `this..()`. Property names - // chosen to avoid collisions with existing state. - this.sequenceOps = typeof MidiEditorSequence !== 'undefined' ? new MidiEditorSequence(this) : null; - this.ccOps = typeof MidiEditorCC !== 'undefined' ? new MidiEditorCC(this) : null; - this.fileOps = typeof MidiEditorFileOps !== 'undefined' ? new MidiEditorFileOps(this) : null; - this.renderer = typeof MidiEditorRenderer !== 'undefined' ? new MidiEditorRenderer(this) : null; - this.routingOps = typeof MidiEditorRouting !== 'undefined' ? new MidiEditorRouting(this) : null; - this.editActions = typeof MidiEditorEditActions !== 'undefined' ? new MidiEditorEditActions(this) : null; - this.events = typeof MidiEditorEvents !== 'undefined' ? new MidiEditorEvents(this) : null; - this.tablatureOps = typeof MidiEditorTablature !== 'undefined' ? new MidiEditorTablature(this) : null; - this.lifecycle = typeof MidiEditorLifecycle !== 'undefined' ? new MidiEditorLifecycle(this) : null; - this.infoModal = typeof MidiEditorInfoModal !== 'undefined' ? new MidiEditorInfoModal(this) : null; - - // Tablature editor (for string instruments) - this.tablatureEditor = null; - - // Drum pattern editor (for percussion channels) - this.drumPatternEditor = null; - - // Wind instrument editor (for brass/reed/pipe channels) - this.windInstrumentEditor = null; - - // Playback (embedded synthesizer) - this.synthesizer = null; - this.isPlaying = false; - this.isPaused = false; - this.playbackStartTick = 0; - this.playbackEndTick = 0 - - // Playback manager (delegate) - this._playback = typeof MidiEditorPlayback !== 'undefined' ? new MidiEditorPlayback(this) : null; - - // Constants shared from MidiEditorConstants - const constants = (typeof MidiEditorConstants !== 'undefined') ? MidiEditorConstants : {}; - this.snapValues = constants.snapValues || [ - { ticks: 120, label: '1/1' }, { ticks: 60, label: '1/2' }, - { ticks: 30, label: '1/4' }, { ticks: 15, label: '1/8' }, { ticks: 1, label: '1/16' } - ]; - this.currentSnapIndex = constants.defaultSnapIndex !== undefined ? constants.defaultSnapIndex : 3; - this.channelColors = constants.channelColors || [ - '#FF0066','#00FFFF','#FF00FF','#FFFF00','#00FF00','#FF6600','#9D00FF','#00FF99', - '#FF0000','#00BFFF','#FFD700','#FF1493','#00FFAA','#FF4500','#7FFF00','#FF69B4' - ]; - this.gmInstruments = constants.gmInstruments || []; + /** + * @param {*} eventBus + * @param {*} apiClient + * @param {Object} [opts] + * @param {boolean} [opts.loopMode=false] Slim layout for the loop editor : + * no file/save header, no instrument/channel/GM selectors, no gear popover, + * mono-channel CC section, inline touch-mode toggle. The host (e.g. + * LoopEditorModal) drives transport/save/instrument from its own shell. + * @param {HTMLElement} [opts.host] When provided, render() injects the + * editor body into this element instead of appending a modal-overlay + * to document.body. Required for panel embedding (mountAsPanel). + */ + constructor(eventBus, apiClient, opts = {}) { + this.eventBus = eventBus; + this.api = apiClient; + this.logger = window.logger || console; + + // Panel/loop integration flags + this.loopMode = opts.loopMode === true; + this.panelHost = opts.host || null; + + this.container = null; + this.isOpen = false; + this.pianoRoll = null; + + // i18n support + this.localeUnsubscribe = null; + + // State + this.currentFile = null; // fileId + this.currentFilename = null; // filename used for display + this.midiData = null; + this.isDirty = false; + + // Note sequence for webaudio-pianoroll + this.sequence = []; + this.fullSequence = []; // All notes (all channels) + this.activeChannels = new Set(); // Active channels to display + this.channels = []; // Information about available channels + + // Clipboard for copy/paste + this.clipboard = []; + + // Current edit mode + this.editMode = 'drag-view'; // 'select', 'drag-notes', 'drag-view', 'edit' - drag-view by default for navigation + + // Touch mode: shows separate Move/Add/Resize buttons instead of the unified pencil button + this.touchMode = this._loadTouchModePref(); + + // Playback feedback preferences + this.keyboardPlaybackEnabled = this._loadKeyboardPlaybackPref(); + this.dragPlaybackEnabled = this._loadDragPlaybackPref(); + + // Instrument selected for new channels (GM MIDI program) + this.selectedInstrument = 0; // Piano by default + + // CC/Pitchbend/Velocity/Tempo Editor + this.ccEditor = null; + this.velocityEditor = null; + this.tempoEditor = null; + this.currentCCType = 'cc1'; // 'cc1', 'cc2', 'cc5', 'cc7', 'cc10', 'cc11', 'cc74', 'cc76', 'cc77', 'cc78', 'cc91', 'pitchbend', 'velocity', 'tempo' + this.ccEvents = []; // CC and pitchbend events + this.tempoEvents = []; // Tempo events + this.ccSectionExpanded = false; // Collapse state of the CC section + + // Connected instrument used for routing + this.connectedDevices = []; // List of connected MIDI devices + + // Per-channel routing: Map (e.g. "deviceId" or "deviceId::channel") + this.channelRouting = new Map(); + // Per-channel disabled state: Set + this.channelDisabled = new Set(); + // Currently open channel settings popover channel (-1 = none) + this._channelSettingsOpen = -1; + // Per-channel playable notes highlights: Map> + this.channelPlayableHighlights = new Map(); + // Cache of routed instrument gm_program per channel: Map + this._routedGmPrograms = new Map(); + // Cache of routed instrument per-instrument custom SF2 id per channel: + // Map (absent/null = use the global sound bank). + this._routedSf2Ids = new Map(); + // Preview source: 'gm' (original MIDI file instruments) or 'routed' (routed instrument gm_program) + this.previewSource = 'gm'; + // Per-channel playable note sets for routed preview: Map|null> + this._routedPlayableNotes = new Map(); + // Global toggle: auto-show playable notes for all routed channels + this.showPlayableNotes = false; + + // Channel panel (manages tablature buttons, device selector, instrument selector) + this.channelPanel = + typeof MidiEditorChannelPanel !== 'undefined' ? new MidiEditorChannelPanel(this) : null; + + // Channel state read facade (audit §4.2). State stays on `this.X` + // for now; consumers can read via `this.channelState.X(...)` and + // we'll route writes through it incrementally. + this.channelState = + typeof MidiEditorChannelState !== 'undefined' ? new MidiEditorChannelState(this) : null; + + // Confirmation dialogs sub-component (P2-F.10a — replaces mixin). + // Instantiated before the mixin loop so the mixin forwarders find it. + this.dialogs = typeof MidiEditorDialogs !== 'undefined' ? new MidiEditorDialogs(this) : null; + + // Draw settings popover sub-component (P2-F.10b). + this.drawSettings = + typeof MidiEditorDrawSettings !== 'undefined' ? new MidiEditorDrawSettings(this) : null; + + // CC picker sub-component (P2-F.10c). All 22 methods now live on + // the class itself — callsites use `modal.ccPicker.(...)`. + // The mixin has been removed from the prototype. + this.ccPicker = typeof MidiEditorCCPicker !== 'undefined' ? new MidiEditorCCPicker(this) : null; + + // Remaining 9 facades (P2-F.10-wire). Thin auto-generated facades + // around the legacy mixins ; callsites migrate progressively from + // `this.()` to `this..()`. Property names + // chosen to avoid collisions with existing state. + this.sequenceOps = + typeof MidiEditorSequence !== 'undefined' ? new MidiEditorSequence(this) : null; + this.ccOps = typeof MidiEditorCC !== 'undefined' ? new MidiEditorCC(this) : null; + this.fileOps = typeof MidiEditorFileOps !== 'undefined' ? new MidiEditorFileOps(this) : null; + this.renderer = typeof MidiEditorRenderer !== 'undefined' ? new MidiEditorRenderer(this) : null; + this.routingOps = typeof MidiEditorRouting !== 'undefined' ? new MidiEditorRouting(this) : null; + this.editActions = + typeof MidiEditorEditActions !== 'undefined' ? new MidiEditorEditActions(this) : null; + this.events = typeof MidiEditorEvents !== 'undefined' ? new MidiEditorEvents(this) : null; + this.tablatureOps = + typeof MidiEditorTablature !== 'undefined' ? new MidiEditorTablature(this) : null; + this.lifecycle = + typeof MidiEditorLifecycle !== 'undefined' ? new MidiEditorLifecycle(this) : null; + this.infoModal = + typeof MidiEditorInfoModal !== 'undefined' ? new MidiEditorInfoModal(this) : null; + + // Tablature editor (for string instruments) + this.tablatureEditor = null; + + // Drum pattern editor (for percussion channels) + this.drumPatternEditor = null; + + // Wind instrument editor (for brass/reed/pipe channels) + this.windInstrumentEditor = null; + + // Playback (embedded synthesizer) + this.synthesizer = null; + this.isPlaying = false; + this.isPaused = false; + this.playbackStartTick = 0; + this.playbackEndTick = 0; + + // Playback manager (delegate) + this._playback = + typeof MidiEditorPlayback !== 'undefined' ? new MidiEditorPlayback(this) : null; + + // Constants shared from MidiEditorConstants + const constants = typeof MidiEditorConstants !== 'undefined' ? MidiEditorConstants : {}; + this.snapValues = constants.snapValues || [ + { ticks: 120, label: '1/1' }, + { ticks: 60, label: '1/2' }, + { ticks: 30, label: '1/4' }, + { ticks: 15, label: '1/8' }, + { ticks: 1, label: '1/16' } + ]; + this.currentSnapIndex = + constants.defaultSnapIndex !== undefined ? constants.defaultSnapIndex : 3; + this.channelColors = constants.channelColors || [ + '#FF0066', + '#00FFFF', + '#FF00FF', + '#FFFF00', + '#00FF00', + '#FF6600', + '#9D00FF', + '#00FF99', + '#FF0000', + '#00BFFF', + '#FFD700', + '#FF1493', + '#00FFAA', + '#FF4500', + '#7FFF00', + '#FF69B4' + ]; + this.gmInstruments = constants.gmInstruments || []; + } + + // ======================================================================== + // LIFECYCLE FORWARDERS (P2-F.10l) + // ------------------------------------------------------------------------ + // Hot lifecycle methods (`log`, `close`, `showNotification`, …) remain + // reachable directly on the modal instance to avoid migrating ~500 call + // sites. Each forwarder delegates to `this.lifecycle` (MidiEditorLifecycle + // sub-component) which now owns the implementation. + // ======================================================================== + + log(level, ...args) { + return this.lifecycle && this.lifecycle.log(level, ...args); + } + close() { + return this.lifecycle && this.lifecycle.close(); + } + doClose() { + return this.lifecycle && this.lifecycle.doClose(); + } + showUnsavedChangesModal() { + return this.lifecycle && this.lifecycle.showUnsavedChangesModal(); + } + setupBeforeUnloadHandler() { + return this.lifecycle && this.lifecycle.setupBeforeUnloadHandler(); + } + removeBeforeUnloadHandler() { + return this.lifecycle && this.lifecycle.removeBeforeUnloadHandler(); + } + showNotification(message, type) { + return this.lifecycle && this.lifecycle.showNotification(message, type); + } + showError(message) { + return this.lifecycle && this.lifecycle.showError(message); + } + showErrorModal(message, title) { + return this.lifecycle && this.lifecycle.showErrorModal(message, title); + } + + // ======================================================================== + // I18N SUPPORT + // ======================================================================== + + /** + * Helper to translate a key + * @param {string} key - Translation key + * @param {Object} params - Interpolation parameters + * @returns {string} - Translated text + */ + t(key, params = {}) { + return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + } + + /** + * Retrieve the translated name of a GM instrument + * @param {number} index - Instrument index (0-127) + * @returns {string} - Translated instrument name + */ + getInstrumentName(index) { + const translatedList = this.t('instruments.list'); + if (Array.isArray(translatedList) && translatedList[index]) { + return translatedList[index]; } - - // ======================================================================== - // LIFECYCLE FORWARDERS (P2-F.10l) - // ------------------------------------------------------------------------ - // Hot lifecycle methods (`log`, `close`, `showNotification`, …) remain - // reachable directly on the modal instance to avoid migrating ~500 call - // sites. Each forwarder delegates to `this.lifecycle` (MidiEditorLifecycle - // sub-component) which now owns the implementation. - // ======================================================================== - - log(level, ...args) { return this.lifecycle && this.lifecycle.log(level, ...args); } - close() { return this.lifecycle && this.lifecycle.close(); } - doClose() { return this.lifecycle && this.lifecycle.doClose(); } - showUnsavedChangesModal() { return this.lifecycle && this.lifecycle.showUnsavedChangesModal(); } - setupBeforeUnloadHandler() { return this.lifecycle && this.lifecycle.setupBeforeUnloadHandler(); } - removeBeforeUnloadHandler() { return this.lifecycle && this.lifecycle.removeBeforeUnloadHandler(); } - showNotification(message, type) { return this.lifecycle && this.lifecycle.showNotification(message, type); } - showError(message) { return this.lifecycle && this.lifecycle.showError(message); } - showErrorModal(message, title) { return this.lifecycle && this.lifecycle.showErrorModal(message, title); } - - // ======================================================================== - // I18N SUPPORT - // ======================================================================== - - /** - * Helper to translate a key - * @param {string} key - Translation key - * @param {Object} params - Interpolation parameters - * @returns {string} - Translated text - */ - t(key, params = {}) { - return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + return this.gmInstruments[index] || `Instrument ${index}`; + } + + // ======================================================================== + // DISPLAY THE MODAL + // ======================================================================== + + /** + * Display the MIDI editor modal + * @param {string} fileId - File id in the database + * @param {string} filename - File name (optional, used for display) + */ + async show(fileId, filename = null) { + if (this.isOpen) { + this.log('warn', 'Modal already open'); + return; } - /** - * Retrieve the translated name of a GM instrument - * @param {number} index - Instrument index (0-127) - * @returns {string} - Translated instrument name - */ - getInstrumentName(index) { - const translatedList = this.t('instruments.list'); - if (Array.isArray(translatedList) && translatedList[index]) { - return translatedList[index]; - } - return this.gmInstruments[index] || `Instrument ${index}`; - } + this.currentFile = fileId; + this.currentFilename = filename || fileId; + this.isDirty = false; + + // Reset routing/disabled state from the previous file + this.channelRouting.clear(); + this.channelDisabled.clear(); + this.channelPlayableHighlights.clear(); + // Also reset routed-preview caches and the preview-source toggle so + // file B doesn't inherit file A's routed playable-notes mask or its + // 'routed' preview mode. `_routedGmPrograms` / `_routedSf2Ids` are + // re-populated by `_loadSavedRoutings()`; clearing defensively + // covers the case where file B has no persisted routings at all. + this._routedGmPrograms.clear(); + this._routedSf2Ids.clear(); + this._routedPlayableNotes.clear(); + this.previewSource = 'gm'; - // ======================================================================== - // DISPLAY THE MODAL - // ======================================================================== - - /** - * Display the MIDI editor modal - * @param {string} fileId - File id in the database - * @param {string} filename - File name (optional, used for display) - */ - async show(fileId, filename = null) { - if (this.isOpen) { - this.log('warn', 'Modal already open'); - return; - } + try { + // Load the MIDI file + await this.loadMidiFile(fileId); - this.currentFile = fileId; - this.currentFilename = filename || fileId; - this.isDirty = false; - - // Reset routing/disabled state from the previous file - this.channelRouting.clear(); - this.channelDisabled.clear(); - this.channelPlayableHighlights.clear(); - // Also reset routed-preview caches and the preview-source toggle so - // file B doesn't inherit file A's routed playable-notes mask or its - // 'routed' preview mode. `_routedGmPrograms` / `_routedSf2Ids` are - // re-populated by `_loadSavedRoutings()`; clearing defensively - // covers the case where file B has no persisted routings at all. - this._routedGmPrograms.clear(); - this._routedSf2Ids.clear(); - this._routedPlayableNotes.clear(); - this.previewSource = 'gm'; - - try { - // Load the MIDI file - await this.loadMidiFile(fileId); - - // Show the modal - this.routingOps.render(); - - // Initialize the piano roll - await this.routingOps.initPianoRoll(); - - // Scan for string instrument configs to reveal TAB buttons - await this.tablatureOps._refreshStringInstrumentChannels(); - - this.isOpen = true; - - // Listen for external routing changes (e.g. from the simple routing modal) - if (this.eventBus) { - this._onExternalRoutingChanged = (data) => { - if (data.fileId === this.currentFile && !this._isEmittingRouting) { - this.tablatureOps._loadSavedRoutings(); - } - }; - this.eventBus.on('routing:changed', this._onExternalRoutingChanged); - } - - // Install the beforeunload handler to prevent closing with unsaved changes - this.setupBeforeUnloadHandler(); - - // Subscribe to locale changes - if (typeof i18n !== 'undefined') { - this.localeUnsubscribe = i18n.onLocaleChange(() => { - // Note: the piano roll is already rendered, we cannot easily re-translate - // but keep the subscription for consistency - }); - } - - // Emit event - if (this.eventBus) { - this.eventBus.emit('midi_editor:opened', { fileId, filename: this.currentFilename }); - } - - } catch (error) { - this.log('error', 'Failed to open MIDI editor:', error); - this.showError(this.t('midiEditor.cannotOpen', { error: error.message })); - } - } + // Show the modal + this.routingOps.render(); - /** - * Open the editor as a panel inside a host element, with an in-memory - * loop sequence instead of a file fetched from the backend. The caller - * (LoopEditorModal) owns persistence, transport, and the instrument - * selector ; we render the slim "loop mode" UI and notify on changes. - * - * @param {HTMLElement} host Container to render the panel into. - * @param {Object} opts - * @param {Array} [opts.sequence] Initial notes {t,g,n,c,v}. - * @param {Array} [opts.ccEvents] Initial CC/PB/AT events. - * @param {number} [opts.tempo=120] Initial tempo BPM. - * @param {number} [opts.ppq=480] Ticks per beat. - * @param {number} [opts.bars=2] Loop length in bars (informational). - * @param {number} [opts.timeSigNum=4] - * @param {number} [opts.timeSigDen=4] - * @param {number} [opts.channel=0] MIDI channel (9 for drum kit). - * @param {number} [opts.instrumentProgram=0] GM program for that channel. - * @param {Function} [opts.onChange] Notified after each edit - * ({ sequence, ccEvents, isDirty }). - */ - async showAsPanel(host, opts = {}) { - if (this.isOpen) { - this.log('warn', 'Editor panel already open'); - return; - } - this.loopMode = true; - this.panelHost = host; - - const tempo = Number.isFinite(opts.tempo) ? opts.tempo : 120; - const ppq = Number.isFinite(opts.ppq) ? opts.ppq : 480; - const ch = Number.isFinite(opts.channel) ? opts.channel : 0; - const prog = Number.isFinite(opts.instrumentProgram) ? opts.instrumentProgram : 0; - const program = prog >= 128 ? prog - 128 : prog; // drum kit offset - - // Synthesize the minimal midiData shape the rest of the editor reads. - // No tracks — the sequence is injected directly into fullSequence below. - this.currentFile = null; - this.currentFilename = opts.name || ''; - this.tempo = tempo; - this.ticksPerBeat = ppq; - this.loopBars = Number.isFinite(opts.bars) ? opts.bars : 2; - this.midiData = { - header: { ticksPerBeat: ppq, timeSignature: [opts.timeSigNum || 4, opts.timeSigDen || 4] }, - tracks: [], - maxTick: ppq * (opts.timeSigNum || 4) * (opts.bars || 2) - }; + // Initialize the piano roll + await this.routingOps.initPianoRoll(); - // Sequence injected as-is (already in webaudio-pianoroll format). - this.fullSequence = Array.isArray(opts.sequence) ? opts.sequence.map(n => ({ ...n, c: ch })) : []; - this.sequence = [...this.fullSequence]; - this.channels = [{ - channel: ch, - program: program, - instrument: ch === 9 ? this.t('midiEditor.drumKit') : this.getInstrumentName(program), - noteCount: this.fullSequence.length, - hasExplicitProgram: true - }]; - this.activeChannels.clear(); - this.activeChannels.add(ch); - this.channelDisabled.clear(); - this.channelPlayableHighlights.clear(); - - // CC + tempo events from caller (optional). - this.ccEvents = Array.isArray(opts.ccEvents) ? [...opts.ccEvents] : []; - this.tempoEvents = []; - - this._panelOnChange = typeof opts.onChange === 'function' ? opts.onChange : null; - this.isDirty = false; - - try { - // Render the slim loop-mode UI into the host. - this.routingOps.render(); - await this.routingOps.initPianoRoll(); - this.isOpen = true; - - // Rebuild dynamic CC buttons from the injected events. - this.ccOps?.updateDynamicCCButtons?.(); - - // Render the specialized-mode toolbar buttons (DRUM / TAB / - // WIND) for the initial instrument. Async because the TAB - // check queries the backend for a string-instrument config. - this.routingOps._updateLoopSpecializedModeButtons?.(); - - // Hook the change pipeline so saves can read back the latest state. - if (this._panelOnChange) { - const notify = () => { - try { - this._panelOnChange({ - sequence: this.fullSequence, - ccEvents: this.ccEvents, - isDirty: this.isDirty - }); - } catch (err) { this.log('error', 'panel onChange threw:', err); } - }; - this._panelChangeListener = notify; - this.pianoRoll?.addEventListener('change', notify); - } - } catch (error) { - this.log('error', 'Failed to open MIDI editor as panel:', error); - this.showError(this.t('midiEditor.cannotOpen', { error: error.message })); - } - } + // Scan for string instrument configs to reveal TAB buttons + await this.tablatureOps._refreshStringInstrumentChannels(); - /** - * Update the current loop sequence from the outside (e.g. when the - * LoopEditorModal records new notes through its keyboard or changes - * tempo/bars). Triggers a piano-roll redraw. - */ - setPanelLoopState({ sequence, tempo, ppq, bars, timeSigNum, timeSigDen, instrumentProgram, channel } = {}) { - if (!this.loopMode) return; - if (Number.isFinite(tempo) && this.pianoRoll) { - this.tempo = tempo; - this.pianoRoll.tempo = tempo; - } - if (Number.isFinite(ppq) && this.pianoRoll) { - this.ticksPerBeat = ppq; - this.pianoRoll.timebase = ppq; - } - if (this.midiData) { - if (Number.isFinite(timeSigNum) || Number.isFinite(timeSigDen)) { - this.midiData.header.timeSignature = [ - Number.isFinite(timeSigNum) ? timeSigNum : (this.midiData.header.timeSignature?.[0] || 4), - Number.isFinite(timeSigDen) ? timeSigDen : (this.midiData.header.timeSignature?.[1] || 4) - ]; - } - if (Number.isFinite(bars) || Number.isFinite(timeSigNum) || Number.isFinite(timeSigDen)) { - const [num] = this.midiData.header.timeSignature || [4]; - const effBars = Number.isFinite(bars) ? bars : ((this.midiData.maxTick || 0) / ((this.ticksPerBeat || 480) * num)) || 2; - this.loopBars = effBars; - this.midiData.maxTick = (this.ticksPerBeat || 480) * num * effBars; - // CanvasPianoRollRenderer ignores element attributes — push - // the new loop boundary + view range through its API so the - // end indicator follows bars / time-signature / tempo edits. - this.pianoRollRenderer?.setMarkers?.(0, this.midiData.maxTick); - this.pianoRollRenderer?.setXRange?.(this.midiData.maxTick); - this.pianoRollRenderer?.setXOffset?.(0); - if (this.pianoRoll) { - this.pianoRoll.setAttribute('markend', String(this.midiData.maxTick)); - // Keep the horizontal zoom locked to the loop length so - // the whole loop stays visible after a tempo / bars / - // time-sig change. The user can still wheel-zoom in. - this.pianoRoll.setAttribute('xrange', String(this.midiData.maxTick)); - this.pianoRoll.setAttribute('xoffset', '0'); - this.pianoRoll.redraw?.(); - } - } - } - if (Number.isFinite(channel) && this.channels[0]) { - this.channels[0].channel = channel; - this.activeChannels.clear(); - this.activeChannels.add(channel); - } - if (Number.isFinite(instrumentProgram) && this.channels[0]) { - const prog = instrumentProgram >= 128 ? instrumentProgram - 128 : instrumentProgram; - this.channels[0].program = prog; - this.channels[0].instrument = (this.channels[0].channel === 9) - ? this.t('midiEditor.drumKit') : this.getInstrumentName(prog); - // Refresh the DRUM / TAB / WIND toolbar — the active mode - // depends on the GM program range. - this.routingOps?._updateLoopSpecializedModeButtons?.(); - } - if (Array.isArray(sequence)) { - const ch = this.channels[0]?.channel ?? 0; - this.fullSequence = sequence.map(n => ({ ...n, c: ch })); - this.sequence = [...this.fullSequence]; - if (this.pianoRoll) { - this.pianoRoll.sequence = this.sequence; - this.pianoRoll.redraw?.(); - } - } - } + this.isOpen = true; - /** - * Unmount the editor panel from its host without the unsaved-changes - * prompt — the host owns persistence and should already have decided - * whether to discard or save before calling. - */ - unmountPanel() { - if (!this.loopMode) { - this.log('warn', 'unmountPanel called outside loop mode — ignoring'); - return; - } - try { - if (this._panelChangeListener && this.pianoRoll) { - this.pianoRoll.removeEventListener('change', this._panelChangeListener); - } - } catch (_) { /* listener already gone */ } - this._panelChangeListener = null; - this._panelOnChange = null; - // Bypass the dirty prompt by clearing isDirty first. - this.isDirty = false; - this.lifecycle?.doClose?.(); - this.panelHost = null; + // Listen for external routing changes (e.g. from the simple routing modal) + if (this.eventBus) { + this._onExternalRoutingChanged = (data) => { + if (data.fileId === this.currentFile && !this._isEmittingRouting) { + this.tablatureOps._loadSavedRoutings(); + } + }; + this.eventBus.on('routing:changed', this._onExternalRoutingChanged); + } + + // Install the beforeunload handler to prevent closing with unsaved changes + this.setupBeforeUnloadHandler(); + + // Subscribe to locale changes + if (typeof i18n !== 'undefined') { + this.localeUnsubscribe = i18n.onLocaleChange(() => { + // Note: the piano roll is already rendered, we cannot easily re-translate + // but keep the subscription for consistency + }); + } + + // Emit event + if (this.eventBus) { + this.eventBus.emit('midi_editor:opened', { fileId, filename: this.currentFilename }); + } + } catch (error) { + this.log('error', 'Failed to open MIDI editor:', error); + this.showError(this.t('midiEditor.cannotOpen', { error: error.message })); } + } + + /** + * Open the editor as a panel inside a host element, with an in-memory + * loop sequence instead of a file fetched from the backend. The caller + * (LoopEditorModal) owns persistence, transport, and the instrument + * selector ; we render the slim "loop mode" UI and notify on changes. + * + * @param {HTMLElement} host Container to render the panel into. + * @param {Object} opts + * @param {Array} [opts.sequence] Initial notes {t,g,n,c,v}. + * @param {Array} [opts.ccEvents] Initial CC/PB/AT events. + * @param {number} [opts.tempo=120] Initial tempo BPM. + * @param {number} [opts.ppq=480] Ticks per beat. + * @param {number} [opts.bars=2] Loop length in bars (informational). + * @param {number} [opts.timeSigNum=4] + * @param {number} [opts.timeSigDen=4] + * @param {number} [opts.channel=0] MIDI channel (9 for drum kit). + * @param {number} [opts.instrumentProgram=0] GM program for that channel. + * @param {Function} [opts.onChange] Notified after each edit + * ({ sequence, ccEvents, isDirty }). + */ + async showAsPanel(host, opts = {}) { + if (this.isOpen) { + this.log('warn', 'Editor panel already open'); + return; + } + this.loopMode = true; + this.panelHost = host; + + const tempo = Number.isFinite(opts.tempo) ? opts.tempo : 120; + const ppq = Number.isFinite(opts.ppq) ? opts.ppq : 480; + const ch = Number.isFinite(opts.channel) ? opts.channel : 0; + const prog = Number.isFinite(opts.instrumentProgram) ? opts.instrumentProgram : 0; + const program = prog >= 128 ? prog - 128 : prog; // drum kit offset + + // Synthesize the minimal midiData shape the rest of the editor reads. + // No tracks — the sequence is injected directly into fullSequence below. + this.currentFile = null; + this.currentFilename = opts.name || ''; + this.tempo = tempo; + this.ticksPerBeat = ppq; + this.loopBars = Number.isFinite(opts.bars) ? opts.bars : 2; + this.midiData = { + header: { ticksPerBeat: ppq, timeSignature: [opts.timeSigNum || 4, opts.timeSigDen || 4] }, + tracks: [], + maxTick: ppq * (opts.timeSigNum || 4) * (opts.bars || 2) + }; + + // Sequence injected as-is (already in webaudio-pianoroll format). + this.fullSequence = Array.isArray(opts.sequence) + ? opts.sequence.map((n) => ({ ...n, c: ch })) + : []; + this.sequence = [...this.fullSequence]; + this.channels = [ + { + channel: ch, + program: program, + instrument: ch === 9 ? this.t('midiEditor.drumKit') : this.getInstrumentName(program), + noteCount: this.fullSequence.length, + hasExplicitProgram: true + } + ]; + this.activeChannels.clear(); + this.activeChannels.add(ch); + this.channelDisabled.clear(); + this.channelPlayableHighlights.clear(); + + // CC + tempo events from caller (optional). + this.ccEvents = Array.isArray(opts.ccEvents) ? [...opts.ccEvents] : []; + this.tempoEvents = []; + + this._panelOnChange = typeof opts.onChange === 'function' ? opts.onChange : null; + this.isDirty = false; - /** - * Read back the current loop state (notes + CC + tempo) so the host - * modal (LoopEditorModal) can persist it. - */ - getPanelLoopState() { - return { - sequence: this.fullSequence ? this.fullSequence.map(n => ({ ...n })) : [], - ccEvents: this.ccEvents ? this.ccEvents.map(ev => ({ ...ev })) : [], - tempo: this.tempo, - ppq: this.ticksPerBeat + try { + // Render the slim loop-mode UI into the host. + this.routingOps.render(); + await this.routingOps.initPianoRoll(); + this.isOpen = true; + + // Rebuild dynamic CC buttons from the injected events. + this.ccOps?.updateDynamicCCButtons?.(); + + // Render the specialized-mode toolbar buttons (DRUM / TAB / + // WIND) for the initial instrument. Async because the TAB + // check queries the backend for a string-instrument config. + this.routingOps._updateLoopSpecializedModeButtons?.(); + + // Hook the change pipeline so saves can read back the latest state. + if (this._panelOnChange) { + const notify = () => { + try { + this._panelOnChange({ + sequence: this.fullSequence, + ccEvents: this.ccEvents, + isDirty: this.isDirty + }); + } catch (err) { + this.log('error', 'panel onChange threw:', err); + } }; + this._panelChangeListener = notify; + this.pianoRoll?.addEventListener('change', notify); + } + } catch (error) { + this.log('error', 'Failed to open MIDI editor as panel:', error); + this.showError(this.t('midiEditor.cannotOpen', { error: error.message })); } - - /** - * Load the MIDI file depuis le backend - */ - async loadMidiFile(fileId) { - try { - this.log('info', `Loading MIDI file: ${this.currentFilename || fileId}`); - - // Use BackendAPIClient.readMidiFile - const response = await this.api.readMidiFile(fileId); - - if (!response || !response.midiData) { - throw new Error('No MIDI data received from server'); - } - - // The backend returns an object with: { id, filename, midi: {...}, size, tracks, duration, tempo } - // Extract the raw MIDI data - const fileData = response.midiData; - this.midiData = fileData.midi || fileData; - - // S'assurer qu'on a bien un objet header et tracks - if (!this.midiData.header || !this.midiData.tracks) { - throw new Error('Invalid MIDI data structure'); - } - - // Convert to sequence for webaudio-pianoroll - this.sequenceOps.convertMidiToSequence(); - - this.log('info', `MIDI file loaded: ${this.midiData.tracks?.length || 0} tracks, ${this.sequence.length} notes`); - - } catch (error) { - this.log('error', 'Failed to load MIDI file:', error); - - // Only surface the "backend not supported" notice when the - // server actually responded with ERR_NOT_FOUND for this command - // (see CommandRegistry: NotFoundError → {code:'ERR_NOT_FOUND', - // command}). The previous substring check on error.message - // ('file_read' / 'Unknown command') also matched timeouts - // ('Command timeout: file_read') and transport failures, hiding - // the real cause behind a misleading i18n message. - if (error.code === 'ERR_NOT_FOUND' && error.command === 'file_read') { - throw new Error(this.t('midiEditor.backendNotSupported')); - } - - throw error; + } + + /** + * Update the current loop sequence from the outside (e.g. when the + * LoopEditorModal records new notes through its keyboard or changes + * tempo/bars). Triggers a piano-roll redraw. + */ + setPanelLoopState({ + sequence, + tempo, + ppq, + bars, + timeSigNum, + timeSigDen, + instrumentProgram, + channel + } = {}) { + if (!this.loopMode) return; + if (Number.isFinite(tempo) && this.pianoRoll) { + this.tempo = tempo; + this.pianoRoll.tempo = tempo; + } + if (Number.isFinite(ppq) && this.pianoRoll) { + this.ticksPerBeat = ppq; + this.pianoRoll.timebase = ppq; + } + if (this.midiData) { + if (Number.isFinite(timeSigNum) || Number.isFinite(timeSigDen)) { + this.midiData.header.timeSignature = [ + Number.isFinite(timeSigNum) ? timeSigNum : this.midiData.header.timeSignature?.[0] || 4, + Number.isFinite(timeSigDen) ? timeSigDen : this.midiData.header.timeSignature?.[1] || 4 + ]; + } + if (Number.isFinite(bars) || Number.isFinite(timeSigNum) || Number.isFinite(timeSigDen)) { + const [num] = this.midiData.header.timeSignature || [4]; + const effBars = Number.isFinite(bars) + ? bars + : (this.midiData.maxTick || 0) / ((this.ticksPerBeat || 480) * num) || 2; + this.loopBars = effBars; + this.midiData.maxTick = (this.ticksPerBeat || 480) * num * effBars; + // CanvasPianoRollRenderer ignores element attributes — push + // the new loop boundary + view range through its API so the + // end indicator follows bars / time-signature / tempo edits. + this.pianoRollRenderer?.setMarkers?.(0, this.midiData.maxTick); + this.pianoRollRenderer?.setXRange?.(this.midiData.maxTick); + this.pianoRollRenderer?.setXOffset?.(0); + if (this.pianoRoll) { + this.pianoRoll.setAttribute('markend', String(this.midiData.maxTick)); + // Keep the horizontal zoom locked to the loop length so + // the whole loop stays visible after a tempo / bars / + // time-sig change. The user can still wheel-zoom in. + this.pianoRoll.setAttribute('xrange', String(this.midiData.maxTick)); + this.pianoRoll.setAttribute('xoffset', '0'); + this.pianoRoll.redraw?.(); } + } } - - // ======================================================================== - // PLAYBACK FACADE - delegates to MidiEditorPlayback - // ======================================================================== - - async initSynthesizer() { return this._playback ? this._playback.initSynthesizer() : false; } - loadSequenceForPlayback() { if (this._playback) this._playback.loadSequenceForPlayback(); } - syncMutedChannels() { if (this._playback) this._playback.syncMutedChannels(); } - updatePlaybackRange() { if (this._playback) this._playback.updatePlaybackRange(); } - getSequenceEndTick() { return this._playback ? this._playback.getSequenceEndTick() : 0; } - async playbackPlay() { if (this._playback) await this._playback.playbackPlay(); } - playbackPause() { if (this._playback) this._playback.playbackPause(); } - playbackStop() { if (this._playback) this._playback.playbackStop(); } - togglePlayback() { if (this._playback) this._playback.togglePlayback(); } - updatePlaybackCursor(tick) { if (this._playback) this._playback.updatePlaybackCursor(tick); } - onPlaybackComplete() { if (this._playback) this._playback.onPlaybackComplete(); } - updatePlaybackButtons() { if (this._playback) this._playback.updatePlaybackButtons(); } - handleNoteFeedback(prev) { if (this._playback) this._playback.handleNoteFeedback(prev); } - async playNoteFeedback(n, v, c) { if (this._playback) await this._playback.playNoteFeedback(n, v, c); } - async playNoteHold(n, v, c) { if (this._playback) await this._playback.playNoteHold(n, v, c); } - releaseNote(n, c) { if (this._playback) this._playback.releaseNote(n, c); } - releaseAllNotes() { if (this._playback) this._playback.releaseAllNotes(); } - disposeSynthesizer() { if (this._playback) this._playback.disposeSynthesizer(); } - + if (Number.isFinite(channel) && this.channels[0]) { + this.channels[0].channel = channel; + this.activeChannels.clear(); + this.activeChannels.add(channel); + } + if (Number.isFinite(instrumentProgram) && this.channels[0]) { + const prog = instrumentProgram >= 128 ? instrumentProgram - 128 : instrumentProgram; + this.channels[0].program = prog; + this.channels[0].instrument = + this.channels[0].channel === 9 + ? this.t('midiEditor.drumKit') + : this.getInstrumentName(prog); + // Refresh the DRUM / TAB / WIND toolbar — the active mode + // depends on the GM program range. + this.routingOps?._updateLoopSpecializedModeButtons?.(); + } + if (Array.isArray(sequence)) { + const ch = this.channels[0]?.channel ?? 0; + this.fullSequence = sequence.map((n) => ({ ...n, c: ch })); + this.sequence = [...this.fullSequence]; + if (this.pianoRoll) { + this.pianoRoll.sequence = this.sequence; + this.pianoRoll.redraw?.(); + } + } + } + + /** + * Unmount the editor panel from its host without the unsaved-changes + * prompt — the host owns persistence and should already have decided + * whether to discard or save before calling. + */ + unmountPanel() { + if (!this.loopMode) { + this.log('warn', 'unmountPanel called outside loop mode — ignoring'); + return; + } + try { + if (this._panelChangeListener && this.pianoRoll) { + this.pianoRoll.removeEventListener('change', this._panelChangeListener); + } + } catch (_) { + /* listener already gone */ + } + this._panelChangeListener = null; + this._panelOnChange = null; + // Bypass the dirty prompt by clearing isDirty first. + this.isDirty = false; + this.lifecycle?.doClose?.(); + this.panelHost = null; + } + + /** + * Read back the current loop state (notes + CC + tempo) so the host + * modal (LoopEditorModal) can persist it. + */ + getPanelLoopState() { + return { + sequence: this.fullSequence ? this.fullSequence.map((n) => ({ ...n })) : [], + ccEvents: this.ccEvents ? this.ccEvents.map((ev) => ({ ...ev })) : [], + tempo: this.tempo, + ppq: this.ticksPerBeat + }; + } + + /** + * Load the MIDI file depuis le backend + */ + async loadMidiFile(fileId) { + try { + this.log('info', `Loading MIDI file: ${this.currentFilename || fileId}`); + + // Use BackendAPIClient.readMidiFile + const response = await this.api.readMidiFile(fileId); + + if (!response || !response.midiData) { + throw new Error('No MIDI data received from server'); + } + + // The backend returns an object with: { id, filename, midi: {...}, size, tracks, duration, tempo } + // Extract the raw MIDI data + const fileData = response.midiData; + this.midiData = fileData.midi || fileData; + + // S'assurer qu'on a bien un objet header et tracks + if (!this.midiData.header || !this.midiData.tracks) { + throw new Error('Invalid MIDI data structure'); + } + + // Convert to sequence for webaudio-pianoroll + this.sequenceOps.convertMidiToSequence(); + + this.log( + 'info', + `MIDI file loaded: ${this.midiData.tracks?.length || 0} tracks, ${this.sequence.length} notes` + ); + } catch (error) { + this.log('error', 'Failed to load MIDI file:', error); + + // Only surface the "backend not supported" notice when the + // server actually responded with ERR_NOT_FOUND for this command + // (see CommandRegistry: NotFoundError → {code:'ERR_NOT_FOUND', + // command}). The previous substring check on error.message + // ('file_read' / 'Unknown command') also matched timeouts + // ('Command timeout: file_read') and transport failures, hiding + // the real cause behind a misleading i18n message. + if (error.code === 'ERR_NOT_FOUND' && error.command === 'file_read') { + throw new Error(this.t('midiEditor.backendNotSupported')); + } + + throw error; + } + } + + // ======================================================================== + // PLAYBACK FACADE - delegates to MidiEditorPlayback + // ======================================================================== + + async initSynthesizer() { + return this._playback ? this._playback.initSynthesizer() : false; + } + loadSequenceForPlayback() { + if (this._playback) this._playback.loadSequenceForPlayback(); + } + syncMutedChannels() { + if (this._playback) this._playback.syncMutedChannels(); + } + updatePlaybackRange() { + if (this._playback) this._playback.updatePlaybackRange(); + } + getSequenceEndTick() { + return this._playback ? this._playback.getSequenceEndTick() : 0; + } + async playbackPlay() { + if (this._playback) await this._playback.playbackPlay(); + } + playbackPause() { + if (this._playback) this._playback.playbackPause(); + } + playbackStop() { + if (this._playback) this._playback.playbackStop(); + } + togglePlayback() { + if (this._playback) this._playback.togglePlayback(); + } + updatePlaybackCursor(tick) { + if (this._playback) this._playback.updatePlaybackCursor(tick); + } + onPlaybackComplete() { + if (this._playback) this._playback.onPlaybackComplete(); + } + updatePlaybackButtons() { + if (this._playback) this._playback.updatePlaybackButtons(); + } + handleNoteFeedback(prev) { + if (this._playback) this._playback.handleNoteFeedback(prev); + } + async playNoteFeedback(n, v, c) { + if (this._playback) await this._playback.playNoteFeedback(n, v, c); + } + async playNoteHold(n, v, c) { + if (this._playback) await this._playback.playNoteHold(n, v, c); + } + releaseNote(n, c) { + if (this._playback) this._playback.releaseNote(n, c); + } + releaseAllNotes() { + if (this._playback) this._playback.releaseAllNotes(); + } + disposeSynthesizer() { + if (this._playback) this._playback.disposeSynthesizer(); + } } // end class MidiEditorModal // ============================================================================ @@ -572,71 +674,70 @@ class MidiEditorModal { // ============================================================================ if (typeof module !== 'undefined' && module.exports) { - module.exports = MidiEditorModal; + module.exports = MidiEditorModal; } if (typeof window !== 'undefined') { - window.MidiEditorModal = MidiEditorModal; - + window.MidiEditorModal = MidiEditorModal; } // ============================================================================ // Preferences (localStorage via gmboop_settings) // ============================================================================ -MidiEditorModal.prototype._getPreference = function(key, defaultValue) { - try { - const saved = localStorage.getItem('gmboop_settings'); - if (!saved) return defaultValue; - const value = JSON.parse(saved)[key]; - return value === undefined ? defaultValue : value; - } catch (e) { - return defaultValue; - } +MidiEditorModal.prototype._getPreference = function (key, defaultValue) { + try { + const saved = localStorage.getItem('gmboop_settings'); + if (!saved) return defaultValue; + const value = JSON.parse(saved)[key]; + return value === undefined ? defaultValue : value; + } catch (e) { + return defaultValue; + } }; -MidiEditorModal.prototype._setPreference = function(key, value) { - try { - const saved = localStorage.getItem('gmboop_settings'); - const settings = saved ? JSON.parse(saved) : {}; - settings[key] = value; - localStorage.setItem('gmboop_settings', JSON.stringify(settings)); - } catch (e) {} +MidiEditorModal.prototype._setPreference = function (key, value) { + try { + const saved = localStorage.getItem('gmboop_settings'); + const settings = saved ? JSON.parse(saved) : {}; + settings[key] = value; + localStorage.setItem('gmboop_settings', JSON.stringify(settings)); + } catch (e) {} }; -MidiEditorModal.prototype._loadTouchModePref = function() { - return this._getPreference('midiEditorTouchMode', false) === true; +MidiEditorModal.prototype._loadTouchModePref = function () { + return this._getPreference('midiEditorTouchMode', false) === true; }; -MidiEditorModal.prototype._saveTouchModePref = function(value) { - this._setPreference('midiEditorTouchMode', value); +MidiEditorModal.prototype._saveTouchModePref = function (value) { + this._setPreference('midiEditorTouchMode', value); }; -MidiEditorModal.prototype._loadKeyboardPlaybackPref = function() { - return this._getPreference('midiEditorKeyboardPlayback', true) === true; +MidiEditorModal.prototype._loadKeyboardPlaybackPref = function () { + return this._getPreference('midiEditorKeyboardPlayback', true) === true; }; -MidiEditorModal.prototype._saveKeyboardPlaybackPref = function(value) { - this._setPreference('midiEditorKeyboardPlayback', value); +MidiEditorModal.prototype._saveKeyboardPlaybackPref = function (value) { + this._setPreference('midiEditorKeyboardPlayback', value); }; -MidiEditorModal.prototype._loadDragPlaybackPref = function() { - return this._getPreference('midiEditorDragPlayback', true) === true; +MidiEditorModal.prototype._loadDragPlaybackPref = function () { + return this._getPreference('midiEditorDragPlayback', true) === true; }; -MidiEditorModal.prototype._saveDragPlaybackPref = function(value) { - this._setPreference('midiEditorDragPlayback', value); +MidiEditorModal.prototype._saveDragPlaybackPref = function (value) { + this._setPreference('midiEditorDragPlayback', value); }; // Toggle delegates → editActions sub-feature. The settings popover and // inline toolbar buttons in MidiEditorEvents.attachEvents() wire their // click handlers as `this.modal.toggleXxx()` — without these delegates, // the modal exposes no method and the click is a TypeError. -MidiEditorModal.prototype.toggleTouchMode = function() { - return this.editActions?.toggleTouchMode(); +MidiEditorModal.prototype.toggleTouchMode = function () { + return this.editActions?.toggleTouchMode(); }; -MidiEditorModal.prototype.toggleKeyboardPlayback = function() { - return this.editActions?.toggleKeyboardPlayback(); +MidiEditorModal.prototype.toggleKeyboardPlayback = function () { + return this.editActions?.toggleKeyboardPlayback(); }; -MidiEditorModal.prototype.toggleDragPlayback = function() { - return this.editActions?.toggleDragPlayback(); +MidiEditorModal.prototype.toggleDragPlayback = function () { + return this.editActions?.toggleDragPlayback(); }; // ============================================================================ @@ -646,8 +747,8 @@ MidiEditorModal.prototype.toggleDragPlayback = function() { // Copy static-like properties to the class itself (for MidiEditorModal.CC_NAMES access) // Sourced from MidiEditorCC class (P2-F.10h — mixin retired). if (typeof MidiEditorCC !== 'undefined') { - if (MidiEditorCC.CC_NAMES) MidiEditorModal.CC_NAMES = MidiEditorCC.CC_NAMES; - if (MidiEditorCC.CC_CATEGORIES) MidiEditorModal.CC_CATEGORIES = MidiEditorCC.CC_CATEGORIES; + if (MidiEditorCC.CC_NAMES) MidiEditorModal.CC_NAMES = MidiEditorCC.CC_NAMES; + if (MidiEditorCC.CC_CATEGORIES) MidiEditorModal.CC_CATEGORIES = MidiEditorCC.CC_CATEGORIES; } // ============================================================================ @@ -659,4 +760,3 @@ if (typeof MidiEditorCC !== 'undefined') { // this.lifecycle). Lifecycle hot methods (log, close, showNotification, …) // are forwarded as modal instance methods to preserve call sites. // ============================================================================ - diff --git a/public/js/features/MinimapUtils.js b/public/js/features/MinimapUtils.js index 91e166562..26ce5eb19 100644 --- a/public/js/features/MinimapUtils.js +++ b/public/js/features/MinimapUtils.js @@ -4,40 +4,40 @@ * * Exposed as window.MinimapUtils (browser) or module.exports (Node/tests). */ -(function() { - 'use strict'; +(function () { + 'use strict'; - const _BAND_FILL_CACHE = new Map(); + const _BAND_FILL_CACHE = new Map(); - /** - * Convert a #rrggbb hex colour to an rgba() string at the given alpha, - * with memoisation so hot draw paths don't re-parse the hex every frame. - * Falls back to a neutral grey when the input is not a valid 7-char hex. - * - * @param {string} hex '#rrggbb' - * @param {number} [alpha=0.18] - * @returns {string} 'rgba(r, g, b, alpha)' - */ - function bandFillRgba(hex, alpha = 0.18) { - const key = `${hex}|${alpha}`; - let v = _BAND_FILL_CACHE.get(key); - if (v) return v; - if (typeof hex !== 'string' || hex.length !== 7 || hex[0] !== '#') { - v = `rgba(107,114,128,${alpha})`; - } else { - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - v = `rgba(${r}, ${g}, ${b}, ${alpha})`; - } - _BAND_FILL_CACHE.set(key, v); - return v; + /** + * Convert a #rrggbb hex colour to an rgba() string at the given alpha, + * with memoisation so hot draw paths don't re-parse the hex every frame. + * Falls back to a neutral grey when the input is not a valid 7-char hex. + * + * @param {string} hex '#rrggbb' + * @param {number} [alpha=0.18] + * @returns {string} 'rgba(r, g, b, alpha)' + */ + function bandFillRgba(hex, alpha = 0.18) { + const key = `${hex}|${alpha}`; + let v = _BAND_FILL_CACHE.get(key); + if (v) return v; + if (typeof hex !== 'string' || hex.length !== 7 || hex[0] !== '#') { + v = `rgba(107,114,128,${alpha})`; + } else { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + v = `rgba(${r}, ${g}, ${b}, ${alpha})`; } + _BAND_FILL_CACHE.set(key, v); + return v; + } - if (typeof window !== 'undefined') { - window.MinimapUtils = Object.freeze({ bandFillRgba }); - } - if (typeof module !== 'undefined' && module.exports) { - module.exports = { bandFillRgba }; - } + if (typeof window !== 'undefined') { + window.MinimapUtils = Object.freeze({ bandFillRgba }); + } + if (typeof module !== 'undefined' && module.exports) { + module.exports = { bandFillRgba }; + } })(); diff --git a/public/js/features/NavigationOverviewBar.js b/public/js/features/NavigationOverviewBar.js index 5125faed7..e974e7953 100644 --- a/public/js/features/NavigationOverviewBar.js +++ b/public/js/features/NavigationOverviewBar.js @@ -13,411 +13,417 @@ // eslint-disable-next-line no-unused-vars class NavigationOverviewBar { - constructor(container, options = {}) { - this.container = container; - this.canvas = document.createElement('canvas'); - this.canvas.style.display = 'block'; - this.canvas.style.width = '100%'; - this.container.appendChild(this.canvas); - this.ctx = this.canvas.getContext('2d'); - - // Layout - this.height = options.height || 20; - this.canvas.style.height = this.height + 'px'; - - // Viewport state - this.xoffset = 0; - this.xrange = 1920; - this.maxTick = 0; - - // Minimap state (optional overlay of channel notes) - this.minimapNotes = null; - this.minimapColor = null; - this.minimapPitchRange = null; - - // Callbacks - this.onNavigate = options.onNavigate || null; - this.onZoom = options.onZoom || null; - - // Interaction state - this._isDragging = false; - this._dragOffsetX = 0; // offset from click to viewport left edge - this._dirty = true; - this._rafId = null; - this._lastNavigateTime = 0; - - // Colors - this.colors = {}; - this.updateTheme(); - - // Bind event handlers - this._onMouseDown = this._handleMouseDown.bind(this); - this._onMouseMove = this._handleMouseMove.bind(this); - this._onMouseUp = this._handleMouseUp.bind(this); - this._onResize = this.resize.bind(this); - - // Mouse events - this.canvas.addEventListener('mousedown', this._onMouseDown); - window.addEventListener('mousemove', this._onMouseMove); - window.addEventListener('mouseup', this._onMouseUp); - - // Wheel event (zoom) - this._onWheel = this._handleWheel.bind(this); - this.canvas.addEventListener('wheel', this._onWheel, { passive: false }); - - // Touch events - this._onTouchStart = this._handleTouchStart.bind(this); - this._onTouchMove = this._handleTouchMove.bind(this); - this._onTouchEnd = this._handleTouchEnd.bind(this); - this.canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); - this.canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); - this.canvas.addEventListener('touchend', this._onTouchEnd, { passive: false }); - - // Resize - window.addEventListener('resize', this._onResize); - - // Theme change - this._onThemeChanged = () => { this.updateTheme(); this._scheduleRender(); }; - document.addEventListener('theme-changed', this._onThemeChanged); - - // Initial sizing & render - this.resize(); - this._scheduleRender(); + constructor(container, options = {}) { + this.container = container; + this.canvas = document.createElement('canvas'); + this.canvas.style.display = 'block'; + this.canvas.style.width = '100%'; + this.container.appendChild(this.canvas); + this.ctx = this.canvas.getContext('2d'); + + // Layout + this.height = options.height || 20; + this.canvas.style.height = this.height + 'px'; + + // Viewport state + this.xoffset = 0; + this.xrange = 1920; + this.maxTick = 0; + + // Minimap state (optional overlay of channel notes) + this.minimapNotes = null; + this.minimapColor = null; + this.minimapPitchRange = null; + + // Callbacks + this.onNavigate = options.onNavigate || null; + this.onZoom = options.onZoom || null; + + // Interaction state + this._isDragging = false; + this._dragOffsetX = 0; // offset from click to viewport left edge + this._dirty = true; + this._rafId = null; + this._lastNavigateTime = 0; + + // Colors + this.colors = {}; + this.updateTheme(); + + // Bind event handlers + this._onMouseDown = this._handleMouseDown.bind(this); + this._onMouseMove = this._handleMouseMove.bind(this); + this._onMouseUp = this._handleMouseUp.bind(this); + this._onResize = this.resize.bind(this); + + // Mouse events + this.canvas.addEventListener('mousedown', this._onMouseDown); + window.addEventListener('mousemove', this._onMouseMove); + window.addEventListener('mouseup', this._onMouseUp); + + // Wheel event (zoom) + this._onWheel = this._handleWheel.bind(this); + this.canvas.addEventListener('wheel', this._onWheel, { passive: false }); + + // Touch events + this._onTouchStart = this._handleTouchStart.bind(this); + this._onTouchMove = this._handleTouchMove.bind(this); + this._onTouchEnd = this._handleTouchEnd.bind(this); + this.canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); + this.canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); + this.canvas.addEventListener('touchend', this._onTouchEnd, { passive: false }); + + // Resize + window.addEventListener('resize', this._onResize); + + // Theme change + this._onThemeChanged = () => { + this.updateTheme(); + this._scheduleRender(); + }; + document.addEventListener('theme-changed', this._onThemeChanged); + + // Initial sizing & render + this.resize(); + this._scheduleRender(); + } + + // ======================================================================== + // THEME + // ======================================================================== + + updateTheme() { + const isDark = document.body.classList.contains('dark-mode'); + + if (isDark) { + this.colors = { + background: '#1e1e2e', + tick: 'rgba(255, 255, 255, 0.06)', + viewportBorder: '#667eea', + viewportFill: 'rgba(102, 126, 234, 0.18)', + border: '#2d3748' + }; + } else { + this.colors = { + background: '#e8e0f0', + tick: 'rgba(118, 75, 162, 0.15)', + viewportBorder: '#764ba2', + viewportFill: 'rgba(118, 75, 162, 0.18)', + border: 'rgba(118, 75, 162, 0.25)' + }; } - // ======================================================================== - // THEME - // ======================================================================== - - updateTheme() { - const isDark = document.body.classList.contains('dark-mode'); - - if (isDark) { - this.colors = { - background: '#1e1e2e', - tick: 'rgba(255, 255, 255, 0.06)', - viewportBorder: '#667eea', - viewportFill: 'rgba(102, 126, 234, 0.18)', - border: '#2d3748', - }; - } else { - this.colors = { - background: '#e8e0f0', - tick: 'rgba(118, 75, 162, 0.15)', - viewportBorder: '#764ba2', - viewportFill: 'rgba(118, 75, 162, 0.18)', - border: 'rgba(118, 75, 162, 0.25)', - }; - } - - this._dirty = true; - this._scheduleRender(); - } - - // ======================================================================== - // PUBLIC API - // ======================================================================== - - setViewport(xoffset, xrange, maxTick) { - if (maxTick !== undefined && maxTick !== null) { - this.maxTick = maxTick; - } - this.xoffset = xoffset || 0; - this.xrange = xrange || 1920; - this._dirty = true; - this._scheduleRender(); - } + this._dirty = true; + this._scheduleRender(); + } - setMinimap(notes, color) { - if (!notes || notes.length === 0) { - this.minimapNotes = null; - this.minimapColor = null; - this.minimapPitchRange = null; - } else { - this.minimapNotes = notes; - this.minimapColor = color || '#888'; - let min = Infinity; - let max = -Infinity; - for (const n of notes) { - if (n.n < min) min = n.n; - if (n.n > max) max = n.n; - } - this.minimapPitchRange = { min, max }; - } - this._dirty = true; - this._scheduleRender(); - } - - resize() { - const dpr = window.devicePixelRatio || 1; - const rect = this.container.getBoundingClientRect(); - this.canvas.width = rect.width * dpr; - this.canvas.height = this.height * dpr; - this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - this._dirty = true; - this._scheduleRender(); - } + // ======================================================================== + // PUBLIC API + // ======================================================================== - destroy() { - this.canvas.removeEventListener('mousedown', this._onMouseDown); - window.removeEventListener('mousemove', this._onMouseMove); - window.removeEventListener('mouseup', this._onMouseUp); - this.canvas.removeEventListener('wheel', this._onWheel); - this.canvas.removeEventListener('touchstart', this._onTouchStart); - this.canvas.removeEventListener('touchmove', this._onTouchMove); - this.canvas.removeEventListener('touchend', this._onTouchEnd); - window.removeEventListener('resize', this._onResize); - document.removeEventListener('theme-changed', this._onThemeChanged); - if (this._rafId) { - cancelAnimationFrame(this._rafId); - this._rafId = null; - } - if (this.canvas.parentNode) { - this.canvas.parentNode.removeChild(this.canvas); - } + setViewport(xoffset, xrange, maxTick) { + if (maxTick !== undefined && maxTick !== null) { + this.maxTick = maxTick; } - - // ======================================================================== - // RENDERING - // ======================================================================== - - _scheduleRender() { - if (this._rafId) return; - this._rafId = requestAnimationFrame(() => { - this._rafId = null; - if (this._dirty) { - this._render(); - this._dirty = false; - } - }); + this.xoffset = xoffset || 0; + this.xrange = xrange || 1920; + this._dirty = true; + this._scheduleRender(); + } + + setMinimap(notes, color) { + if (!notes || notes.length === 0) { + this.minimapNotes = null; + this.minimapColor = null; + this.minimapPitchRange = null; + } else { + this.minimapNotes = notes; + this.minimapColor = color || '#888'; + let min = Infinity; + let max = -Infinity; + for (const n of notes) { + if (n.n < min) min = n.n; + if (n.n > max) max = n.n; + } + this.minimapPitchRange = { min, max }; } - - _render() { - const ctx = this.ctx; - const w = this.canvas.width / (window.devicePixelRatio || 1); - const h = this.height; - - // Background - ctx.fillStyle = this.colors.background; - ctx.fillRect(0, 0, w, h); - - if (this.maxTick <= 0) { - // No data - just draw background - return; - } - - // Tick marks (light graduation) - this._renderTickMarks(ctx, w, h); - - // Channel minimap (only drawn if set, e.g. single-channel edit mode) - this._renderMinimap(ctx, w, h); - - // Viewport rectangle - const vpRect = this._getViewportRect(w, h); - ctx.fillStyle = this.colors.viewportFill; - ctx.fillRect(vpRect.x, vpRect.y, vpRect.w, vpRect.h); - ctx.strokeStyle = this.colors.viewportBorder; - ctx.lineWidth = 1.5; - ctx.strokeRect(vpRect.x, vpRect.y, vpRect.w, vpRect.h); - - // Bottom border - ctx.strokeStyle = this.colors.border; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(0, h - 0.5); - ctx.lineTo(w, h - 0.5); - ctx.stroke(); + this._dirty = true; + this._scheduleRender(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.container.getBoundingClientRect(); + this.canvas.width = rect.width * dpr; + this.canvas.height = this.height * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this._dirty = true; + this._scheduleRender(); + } + + destroy() { + this.canvas.removeEventListener('mousedown', this._onMouseDown); + window.removeEventListener('mousemove', this._onMouseMove); + window.removeEventListener('mouseup', this._onMouseUp); + this.canvas.removeEventListener('wheel', this._onWheel); + this.canvas.removeEventListener('touchstart', this._onTouchStart); + this.canvas.removeEventListener('touchmove', this._onTouchMove); + this.canvas.removeEventListener('touchend', this._onTouchEnd); + window.removeEventListener('resize', this._onResize); + document.removeEventListener('theme-changed', this._onThemeChanged); + if (this._rafId) { + cancelAnimationFrame(this._rafId); + this._rafId = null; } - - _renderTickMarks(ctx, w, h) { - // Draw light vertical lines to give a sense of scale - // Adapt number of marks based on maxTick - const targetMarks = Math.max(4, Math.min(40, Math.floor(w / 30))); - const tickSpacing = this.maxTick / targetMarks; - - // Round to nice values - const niceValues = [120, 240, 480, 960, 1920, 3840, 7680, 15360, 30720, 61440]; - let spacing = niceValues[0]; - for (const nv of niceValues) { - if (nv >= tickSpacing) { spacing = nv; break; } - spacing = nv; - } - - ctx.strokeStyle = this.colors.tick; - ctx.lineWidth = 1; - ctx.beginPath(); - - for (let tick = spacing; tick < this.maxTick; tick += spacing) { - const x = Math.round((tick / this.maxTick) * w) + 0.5; - ctx.moveTo(x, 0); - ctx.lineTo(x, h); - } - ctx.stroke(); + if (this.canvas.parentNode) { + this.canvas.parentNode.removeChild(this.canvas); } - - _renderMinimap(ctx, w, h) { - if (!this.minimapNotes || this.maxTick <= 0) return; - - const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); - if (effectiveMax <= 0) return; - - const padTop = 1; - const padBottom = 1; - const usableH = Math.max(0, h - padTop - padBottom); - const barH = 2; - const range = this.minimapPitchRange; - const spread = range ? Math.max(0, range.max - range.min) : 0; - const centerY = padTop + (usableH - barH) / 2; - - ctx.save(); - ctx.globalAlpha = 0.45; - ctx.fillStyle = this.minimapColor; - - for (const note of this.minimapNotes) { - const x = (note.t / effectiveMax) * w; - const barW = Math.max(1, (note.g / effectiveMax) * w); - let y; - if (spread === 0) { - y = centerY; - } else { - const norm = (note.n - range.min) / spread; - y = padTop + (1 - norm) * (usableH - barH); - } - ctx.fillRect(x, y, barW, barH); - } - - ctx.restore(); + } + + // ======================================================================== + // RENDERING + // ======================================================================== + + _scheduleRender() { + if (this._rafId) return; + this._rafId = requestAnimationFrame(() => { + this._rafId = null; + if (this._dirty) { + this._render(); + this._dirty = false; + } + }); + } + + _render() { + const ctx = this.ctx; + const w = this.canvas.width / (window.devicePixelRatio || 1); + const h = this.height; + + // Background + ctx.fillStyle = this.colors.background; + ctx.fillRect(0, 0, w, h); + + if (this.maxTick <= 0) { + // No data - just draw background + return; } - _getViewportRect(w, h) { - const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); - const x = (this.xoffset / effectiveMax) * w; - let vpW = (this.xrange / effectiveMax) * w; - // Minimum width so it stays clickable - vpW = Math.max(vpW, 16); - // Clamp to canvas - const clampedX = Math.min(x, w - vpW); - return { x: Math.max(0, clampedX), y: 1, w: vpW, h: h - 2 }; + // Tick marks (light graduation) + this._renderTickMarks(ctx, w, h); + + // Channel minimap (only drawn if set, e.g. single-channel edit mode) + this._renderMinimap(ctx, w, h); + + // Viewport rectangle + const vpRect = this._getViewportRect(w, h); + ctx.fillStyle = this.colors.viewportFill; + ctx.fillRect(vpRect.x, vpRect.y, vpRect.w, vpRect.h); + ctx.strokeStyle = this.colors.viewportBorder; + ctx.lineWidth = 1.5; + ctx.strokeRect(vpRect.x, vpRect.y, vpRect.w, vpRect.h); + + // Bottom border + ctx.strokeStyle = this.colors.border; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h - 0.5); + ctx.lineTo(w, h - 0.5); + ctx.stroke(); + } + + _renderTickMarks(ctx, w, h) { + // Draw light vertical lines to give a sense of scale + // Adapt number of marks based on maxTick + const targetMarks = Math.max(4, Math.min(40, Math.floor(w / 30))); + const tickSpacing = this.maxTick / targetMarks; + + // Round to nice values + const niceValues = [120, 240, 480, 960, 1920, 3840, 7680, 15360, 30720, 61440]; + let spacing = niceValues[0]; + for (const nv of niceValues) { + if (nv >= tickSpacing) { + spacing = nv; + break; + } + spacing = nv; } - // ======================================================================== - // INTERACTION - // ======================================================================== + ctx.strokeStyle = this.colors.tick; + ctx.lineWidth = 1; + ctx.beginPath(); - _getCanvasX(e) { - const rect = this.canvas.getBoundingClientRect(); - return e.clientX - rect.left; + for (let tick = spacing; tick < this.maxTick; tick += spacing) { + const x = Math.round((tick / this.maxTick) * w) + 0.5; + ctx.moveTo(x, 0); + ctx.lineTo(x, h); } - - _isInsideViewport(canvasX) { - const w = this.canvas.width / (window.devicePixelRatio || 1); - const vpRect = this._getViewportRect(w, this.height); - return canvasX >= vpRect.x && canvasX <= vpRect.x + vpRect.w; + ctx.stroke(); + } + + _renderMinimap(ctx, w, h) { + if (!this.minimapNotes || this.maxTick <= 0) return; + + const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); + if (effectiveMax <= 0) return; + + const padTop = 1; + const padBottom = 1; + const usableH = Math.max(0, h - padTop - padBottom); + const barH = 2; + const range = this.minimapPitchRange; + const spread = range ? Math.max(0, range.max - range.min) : 0; + const centerY = padTop + (usableH - barH) / 2; + + ctx.save(); + ctx.globalAlpha = 0.45; + ctx.fillStyle = this.minimapColor; + + for (const note of this.minimapNotes) { + const x = (note.t / effectiveMax) * w; + const barW = Math.max(1, (note.g / effectiveMax) * w); + let y; + if (spread === 0) { + y = centerY; + } else { + const norm = (note.n - range.min) / spread; + y = padTop + (1 - norm) * (usableH - barH); + } + ctx.fillRect(x, y, barW, barH); } - _navigateToX(canvasX) { - const w = this.canvas.width / (window.devicePixelRatio || 1); - if (w <= 0 || this.maxTick <= 0) return; - - const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); - const maxOffset = Math.max(0, effectiveMax - this.xrange); - - // Center the viewport on the clicked position - const clickTick = (canvasX / w) * effectiveMax; - const newOffset = Math.max(0, Math.min(maxOffset, clickTick - this.xrange / 2)); - const percentage = maxOffset > 0 ? (newOffset / maxOffset) * 100 : 0; - - if (this.onNavigate) { - this.onNavigate(percentage); - } + ctx.restore(); + } + + _getViewportRect(w, h) { + const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); + const x = (this.xoffset / effectiveMax) * w; + let vpW = (this.xrange / effectiveMax) * w; + // Minimum width so it stays clickable + vpW = Math.max(vpW, 16); + // Clamp to canvas + const clampedX = Math.min(x, w - vpW); + return { x: Math.max(0, clampedX), y: 1, w: vpW, h: h - 2 }; + } + + // ======================================================================== + // INTERACTION + // ======================================================================== + + _getCanvasX(e) { + const rect = this.canvas.getBoundingClientRect(); + return e.clientX - rect.left; + } + + _isInsideViewport(canvasX) { + const w = this.canvas.width / (window.devicePixelRatio || 1); + const vpRect = this._getViewportRect(w, this.height); + return canvasX >= vpRect.x && canvasX <= vpRect.x + vpRect.w; + } + + _navigateToX(canvasX) { + const w = this.canvas.width / (window.devicePixelRatio || 1); + if (w <= 0 || this.maxTick <= 0) return; + + const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); + const maxOffset = Math.max(0, effectiveMax - this.xrange); + + // Center the viewport on the clicked position + const clickTick = (canvasX / w) * effectiveMax; + const newOffset = Math.max(0, Math.min(maxOffset, clickTick - this.xrange / 2)); + const percentage = maxOffset > 0 ? (newOffset / maxOffset) * 100 : 0; + + if (this.onNavigate) { + this.onNavigate(percentage); } + } - _navigateDrag(canvasX) { - const now = Date.now(); - if (now - this._lastNavigateTime < 16) return; // throttle ~60fps - this._lastNavigateTime = now; - - const w = this.canvas.width / (window.devicePixelRatio || 1); - if (w <= 0 || this.maxTick <= 0) return; - - const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); - const maxOffset = Math.max(0, effectiveMax - this.xrange); - - // Calculate viewport left edge from drag - const vpLeftTick = ((canvasX - this._dragOffsetX) / w) * effectiveMax; - const newOffset = Math.max(0, Math.min(maxOffset, vpLeftTick)); - const percentage = maxOffset > 0 ? (newOffset / maxOffset) * 100 : 0; - - if (this.onNavigate) { - this.onNavigate(percentage); - } - } + _navigateDrag(canvasX) { + const now = Date.now(); + if (now - this._lastNavigateTime < 16) return; // throttle ~60fps + this._lastNavigateTime = now; - _handleWheel(e) { - e.preventDefault(); - if (!this.onZoom) return; - // deltaY > 0 = scroll down = zoom out, deltaY < 0 = scroll up = zoom in - const factor = e.deltaY > 0 ? 1.25 : 0.8; - this.onZoom(factor); - } + const w = this.canvas.width / (window.devicePixelRatio || 1); + if (w <= 0 || this.maxTick <= 0) return; - _handleMouseDown(e) { - e.preventDefault(); - const x = this._getCanvasX(e); - this._isDragging = true; - - if (this._isInsideViewport(x)) { - // Drag the viewport - calculate offset from viewport left edge - const w = this.canvas.width / (window.devicePixelRatio || 1); - const vpRect = this._getViewportRect(w, this.height); - this._dragOffsetX = x - vpRect.x; - this.canvas.style.cursor = 'grabbing'; - } else { - // Click outside - jump to position, then allow drag - this._navigateToX(x); - // Set drag offset to center of viewport - const w = this.canvas.width / (window.devicePixelRatio || 1); - const vpRect = this._getViewportRect(w, this.height); - this._dragOffsetX = vpRect.w / 2; - this.canvas.style.cursor = 'grabbing'; - } - } + const effectiveMax = Math.max(this.maxTick, this.xoffset + this.xrange); + const maxOffset = Math.max(0, effectiveMax - this.xrange); - _handleMouseMove(e) { - if (this._isDragging) { - const x = this._getCanvasX(e); - this._navigateDrag(x); - } else { - // Update cursor - const x = this._getCanvasX(e); - this.canvas.style.cursor = this._isInsideViewport(x) ? 'grab' : 'pointer'; - } - } + // Calculate viewport left edge from drag + const vpLeftTick = ((canvasX - this._dragOffsetX) / w) * effectiveMax; + const newOffset = Math.max(0, Math.min(maxOffset, vpLeftTick)); + const percentage = maxOffset > 0 ? (newOffset / maxOffset) * 100 : 0; - _handleMouseUp() { - if (this._isDragging) { - this._isDragging = false; - this.canvas.style.cursor = 'default'; - } + if (this.onNavigate) { + this.onNavigate(percentage); } - - _handleTouchStart(e) { - e.preventDefault(); - if (e.touches.length !== 1) return; - const touch = e.touches[0]; - const fakeEvent = { clientX: touch.clientX, preventDefault: () => {} }; - this._handleMouseDown(fakeEvent); + } + + _handleWheel(e) { + e.preventDefault(); + if (!this.onZoom) return; + // deltaY > 0 = scroll down = zoom out, deltaY < 0 = scroll up = zoom in + const factor = e.deltaY > 0 ? 1.25 : 0.8; + this.onZoom(factor); + } + + _handleMouseDown(e) { + e.preventDefault(); + const x = this._getCanvasX(e); + this._isDragging = true; + + if (this._isInsideViewport(x)) { + // Drag the viewport - calculate offset from viewport left edge + const w = this.canvas.width / (window.devicePixelRatio || 1); + const vpRect = this._getViewportRect(w, this.height); + this._dragOffsetX = x - vpRect.x; + this.canvas.style.cursor = 'grabbing'; + } else { + // Click outside - jump to position, then allow drag + this._navigateToX(x); + // Set drag offset to center of viewport + const w = this.canvas.width / (window.devicePixelRatio || 1); + const vpRect = this._getViewportRect(w, this.height); + this._dragOffsetX = vpRect.w / 2; + this.canvas.style.cursor = 'grabbing'; } - - _handleTouchMove(e) { - e.preventDefault(); - if (e.touches.length !== 1) return; - const touch = e.touches[0]; - this._handleMouseMove({ clientX: touch.clientX }); + } + + _handleMouseMove(e) { + if (this._isDragging) { + const x = this._getCanvasX(e); + this._navigateDrag(x); + } else { + // Update cursor + const x = this._getCanvasX(e); + this.canvas.style.cursor = this._isInsideViewport(x) ? 'grab' : 'pointer'; } + } - _handleTouchEnd(e) { - e.preventDefault(); - this._handleMouseUp(); + _handleMouseUp() { + if (this._isDragging) { + this._isDragging = false; + this.canvas.style.cursor = 'default'; } + } + + _handleTouchStart(e) { + e.preventDefault(); + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + const fakeEvent = { clientX: touch.clientX, preventDefault: () => {} }; + this._handleMouseDown(fakeEvent); + } + + _handleTouchMove(e) { + e.preventDefault(); + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + this._handleMouseMove({ clientX: touch.clientX }); + } + + _handleTouchEnd(e) { + e.preventDefault(); + this._handleMouseUp(); + } } diff --git a/public/js/features/NeckDiagramConfig.js b/public/js/features/NeckDiagramConfig.js index eb39403a7..792e9fe50 100644 --- a/public/js/features/NeckDiagramConfig.js +++ b/public/js/features/NeckDiagramConfig.js @@ -7,595 +7,609 @@ // ============================================================================ class NeckDiagramConfig { - /** - * @param {HTMLCanvasElement} canvas - * @param {Object} options - * @param {number} options.numStrings - Number of strings - * @param {number} options.numFrets - Default/max fret count - * @param {number[]} [options.fretsPerString] - Per-string fret counts (null = uniform) - * @param {number[]} [options.tuning] - MIDI note numbers per string - * @param {boolean} [options.isFretless] - Whether instrument is fretless - * @param {Function} [options.onChange] - Callback when frets change: onChange(fretsPerString) - */ - constructor(canvas, options = {}) { - this.canvas = canvas; - this.ctx = canvas.getContext('2d'); - - this.numStrings = options.numStrings || 6; - this.numFrets = options.numFrets || 24; - this.tuning = options.tuning || []; - this.isFretless = options.isFretless || false; - this.onChange = options.onChange || null; - - // Per-string fret counts (initialized from options or uniform) - this.fretsPerString = options.fretsPerString - ? [...options.fretsPerString] - : new Array(this.numStrings).fill(this.numFrets); - - // Layout constants (vertical orientation) - this.headHeight = 30; // Guitar head area (top) - this.bodyHeight = 20; // Simplified body (bottom) - this.topMargin = 4; // Small top margin (labels are in HTML above) - this.bottomMargin = 10; - this.leftMargin = 30; // Space for fret numbers - this.rightMargin = 10; - - // Fret markers - this.markerFrets = [3, 5, 7, 9, 12, 15, 17, 19, 21, 24]; - this.doubleMarkerFrets = [12, 24]; - - // Interaction state - this.dragging = null; // { stringIndex } - this.hoveredString = -1; - - // Note names for string labels - this.NOTE_NAMES = MidiConstants.NOTE_NAMES; - - // Colors - this.colors = {}; - this.updateTheme(); - - // Bind events - this._onMouseDown = this._onMouseDown.bind(this); - this._onMouseMove = this._onMouseMove.bind(this); - this._onMouseUp = this._onMouseUp.bind(this); - this._onMouseLeave = this._onMouseLeave.bind(this); - - canvas.addEventListener('mousedown', this._onMouseDown); - canvas.addEventListener('mousemove', this._onMouseMove); - canvas.addEventListener('mouseup', this._onMouseUp); - canvas.addEventListener('mouseleave', this._onMouseLeave); - - // Touch support - this._onTouchStart = this._onTouchStart.bind(this); - this._onTouchMove = this._onTouchMove.bind(this); - this._onTouchEnd = this._onTouchEnd.bind(this); - canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); - canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); - canvas.addEventListener('touchend', this._onTouchEnd); - - this.redraw(); - } - - // ======================================================================== - // THEME - // ======================================================================== - - updateTheme() { - const isDark = document.body.classList.contains('dark-mode'); - - if (isDark) { - this.colors = { - background: '#1a1a2e', - neck: '#2d1f0e', - fretWire: '#555555', - nut: '#999999', - string: '#888888', - stringLabel: '#a0aec0', - fretNumber: '#718096', - marker: 'rgba(255,255,255,0.06)', - markerDot: 'rgba(255,255,255,0.15)', - handle: '#667eea', - handleHover: '#8899ff', - handleText: '#ffffff', - bodyFill: '#1f1508', - bodyStroke: '#3d2f1a', - inactiveZone: 'rgba(255,255,255,0.04)', - }; - } else { - this.colors = { - background: '#f0f4ff', - neck: '#c8b898', - fretWire: '#a0a0b8', - nut: '#e8e0d8', - string: '#5a6089', - stringLabel: '#5a6089', - fretNumber: '#9498b8', - marker: 'rgba(102,126,234,0.1)', - markerDot: 'rgba(102,126,234,0.25)', - handle: '#667eea', - handleHover: '#5a6fd6', - handleText: '#ffffff', - bodyFill: '#b8a888', - bodyStroke: '#a09878', - inactiveZone: 'rgba(102,126,234,0.06)', - }; - } - } - - // ======================================================================== - // CONFIG - // ======================================================================== - - setConfig(options) { - if (options.numStrings !== undefined) this.numStrings = options.numStrings; - if (options.numFrets !== undefined) this.numFrets = options.numFrets; - if (options.tuning !== undefined) this.tuning = options.tuning; - if (options.isFretless !== undefined) this.isFretless = options.isFretless; - - if (options.fretsPerString) { - this.fretsPerString = [...options.fretsPerString]; - } - - // Ensure fretsPerString matches numStrings - while (this.fretsPerString.length < this.numStrings) { - this.fretsPerString.push(this.numFrets); - } - while (this.fretsPerString.length > this.numStrings) { - this.fretsPerString.pop(); - } - - this.redraw(); + /** + * @param {HTMLCanvasElement} canvas + * @param {Object} options + * @param {number} options.numStrings - Number of strings + * @param {number} options.numFrets - Default/max fret count + * @param {number[]} [options.fretsPerString] - Per-string fret counts (null = uniform) + * @param {number[]} [options.tuning] - MIDI note numbers per string + * @param {boolean} [options.isFretless] - Whether instrument is fretless + * @param {Function} [options.onChange] - Callback when frets change: onChange(fretsPerString) + */ + constructor(canvas, options = {}) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + this.numStrings = options.numStrings || 6; + this.numFrets = options.numFrets || 24; + this.tuning = options.tuning || []; + this.isFretless = options.isFretless || false; + this.onChange = options.onChange || null; + + // Per-string fret counts (initialized from options or uniform) + this.fretsPerString = options.fretsPerString + ? [...options.fretsPerString] + : new Array(this.numStrings).fill(this.numFrets); + + // Layout constants (vertical orientation) + this.headHeight = 30; // Guitar head area (top) + this.bodyHeight = 20; // Simplified body (bottom) + this.topMargin = 4; // Small top margin (labels are in HTML above) + this.bottomMargin = 10; + this.leftMargin = 30; // Space for fret numbers + this.rightMargin = 10; + + // Fret markers + this.markerFrets = [3, 5, 7, 9, 12, 15, 17, 19, 21, 24]; + this.doubleMarkerFrets = [12, 24]; + + // Interaction state + this.dragging = null; // { stringIndex } + this.hoveredString = -1; + + // Note names for string labels + this.NOTE_NAMES = MidiConstants.NOTE_NAMES; + + // Colors + this.colors = {}; + this.updateTheme(); + + // Bind events + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + this._onMouseUp = this._onMouseUp.bind(this); + this._onMouseLeave = this._onMouseLeave.bind(this); + + canvas.addEventListener('mousedown', this._onMouseDown); + canvas.addEventListener('mousemove', this._onMouseMove); + canvas.addEventListener('mouseup', this._onMouseUp); + canvas.addEventListener('mouseleave', this._onMouseLeave); + + // Touch support + this._onTouchStart = this._onTouchStart.bind(this); + this._onTouchMove = this._onTouchMove.bind(this); + this._onTouchEnd = this._onTouchEnd.bind(this); + canvas.addEventListener('touchstart', this._onTouchStart, { passive: false }); + canvas.addEventListener('touchmove', this._onTouchMove, { passive: false }); + canvas.addEventListener('touchend', this._onTouchEnd); + + this.redraw(); + } + + // ======================================================================== + // THEME + // ======================================================================== + + updateTheme() { + const isDark = document.body.classList.contains('dark-mode'); + + if (isDark) { + this.colors = { + background: '#1a1a2e', + neck: '#2d1f0e', + fretWire: '#555555', + nut: '#999999', + string: '#888888', + stringLabel: '#a0aec0', + fretNumber: '#718096', + marker: 'rgba(255,255,255,0.06)', + markerDot: 'rgba(255,255,255,0.15)', + handle: '#667eea', + handleHover: '#8899ff', + handleText: '#ffffff', + bodyFill: '#1f1508', + bodyStroke: '#3d2f1a', + inactiveZone: 'rgba(255,255,255,0.04)' + }; + } else { + this.colors = { + background: '#f0f4ff', + neck: '#c8b898', + fretWire: '#a0a0b8', + nut: '#e8e0d8', + string: '#5a6089', + stringLabel: '#5a6089', + fretNumber: '#9498b8', + marker: 'rgba(102,126,234,0.1)', + markerDot: 'rgba(102,126,234,0.25)', + handle: '#667eea', + handleHover: '#5a6fd6', + handleText: '#ffffff', + bodyFill: '#b8a888', + bodyStroke: '#a09878', + inactiveZone: 'rgba(102,126,234,0.06)' + }; } + } - setUniformFrets(numFrets) { - this.numFrets = numFrets; - this.fretsPerString = new Array(this.numStrings).fill(numFrets); - this.redraw(); - if (this.onChange) this.onChange(null); // null = uniform - } + // ======================================================================== + // CONFIG + // ======================================================================== - getFretsPerString() { - // If all equal to numFrets, return null (uniform mode) - if (this.fretsPerString.every(f => f === this.numFrets)) return null; - return [...this.fretsPerString]; - } + setConfig(options) { + if (options.numStrings !== undefined) this.numStrings = options.numStrings; + if (options.numFrets !== undefined) this.numFrets = options.numFrets; + if (options.tuning !== undefined) this.tuning = options.tuning; + if (options.isFretless !== undefined) this.isFretless = options.isFretless; - // ======================================================================== - // LAYOUT HELPERS (vertical orientation) - // ======================================================================== - - _getNeckBounds() { - const w = this.canvas.width; - const h = this.canvas.height; - return { - x: this.leftMargin, - y: this.topMargin + this.headHeight, - width: w - this.leftMargin - this.rightMargin, - height: h - this.topMargin - this.headHeight - this.bodyHeight - this.bottomMargin - }; + if (options.fretsPerString) { + this.fretsPerString = [...options.fretsPerString]; } - _getStringX(stringIndex) { - const neck = this._getNeckBounds(); - const spacing = neck.width / (this.numStrings + 1); - // String 0 = lowest pitch = leftmost - return neck.x + (stringIndex + 1) * spacing; + // Ensure fretsPerString matches numStrings + while (this.fretsPerString.length < this.numStrings) { + this.fretsPerString.push(this.numFrets); } - - _getFretY(fretNum) { - const neck = this._getNeckBounds(); - if (this.numFrets === 0) return neck.y; - return neck.y + (fretNum / this.numFrets) * neck.height; + while (this.fretsPerString.length > this.numStrings) { + this.fretsPerString.pop(); } - _yToFret(y) { - const neck = this._getNeckBounds(); - if (this.numFrets === 0) return 0; - const fret = Math.round(((y - neck.y) / neck.height) * this.numFrets); - return Math.max(0, Math.min(this.numFrets, fret)); + this.redraw(); + } + + setUniformFrets(numFrets) { + this.numFrets = numFrets; + this.fretsPerString = new Array(this.numStrings).fill(numFrets); + this.redraw(); + if (this.onChange) this.onChange(null); // null = uniform + } + + getFretsPerString() { + // If all equal to numFrets, return null (uniform mode) + if (this.fretsPerString.every((f) => f === this.numFrets)) return null; + return [...this.fretsPerString]; + } + + // ======================================================================== + // LAYOUT HELPERS (vertical orientation) + // ======================================================================== + + _getNeckBounds() { + const w = this.canvas.width; + const h = this.canvas.height; + return { + x: this.leftMargin, + y: this.topMargin + this.headHeight, + width: w - this.leftMargin - this.rightMargin, + height: h - this.topMargin - this.headHeight - this.bodyHeight - this.bottomMargin + }; + } + + _getStringX(stringIndex) { + const neck = this._getNeckBounds(); + const spacing = neck.width / (this.numStrings + 1); + // String 0 = lowest pitch = leftmost + return neck.x + (stringIndex + 1) * spacing; + } + + _getFretY(fretNum) { + const neck = this._getNeckBounds(); + if (this.numFrets === 0) return neck.y; + return neck.y + (fretNum / this.numFrets) * neck.height; + } + + _yToFret(y) { + const neck = this._getNeckBounds(); + if (this.numFrets === 0) return 0; + const fret = Math.round(((y - neck.y) / neck.height) * this.numFrets); + return Math.max(0, Math.min(this.numFrets, fret)); + } + + _getStringAtX(x) { + const neck = this._getNeckBounds(); + const spacing = neck.width / (this.numStrings + 1); + for (let i = 0; i < this.numStrings; i++) { + const sx = this._getStringX(i); + if (Math.abs(x - sx) < spacing * 0.45) return i; } - - _getStringAtX(x) { - const neck = this._getNeckBounds(); - const spacing = neck.width / (this.numStrings + 1); - for (let i = 0; i < this.numStrings; i++) { - const sx = this._getStringX(i); - if (Math.abs(x - sx) < spacing * 0.45) return i; - } - return -1; + return -1; + } + + // ======================================================================== + // RENDERING + // ======================================================================== + + redraw() { + const { canvas, ctx } = this; + const w = canvas.width; + const h = canvas.height; + const neck = this._getNeckBounds(); + + // Clear + ctx.fillStyle = this.colors.background; + ctx.fillRect(0, 0, w, h); + + // Draw simplified guitar head (top) + this._drawHead(neck, w); + + // Draw neck background + ctx.fillStyle = this.colors.neck; + ctx.fillRect(neck.x, neck.y, neck.width, neck.height); + + // Draw simplified body (bottom) + this._drawBody(neck, w); + + // Draw fret markers + this._drawFretMarkers(neck); + + // Draw frets (horizontal lines) + this._drawFrets(neck); + + // Draw nut (horizontal bar at top) + this._drawNut(neck); + + // Draw inactive zones per string + this._drawInactiveZones(neck); + + // Draw strings (vertical lines) + this._drawStrings(neck); + + // Draw fret numbers (left margin) + this._drawFretNumbers(neck); + + // Draw draggable handles + this._drawHandles(neck); + } + + _drawHead(neck, _w) { + const ctx = this.ctx; + const headY = this.topMargin; + + // Simplified head shape (horizontal, at top) + ctx.fillStyle = this.colors.bodyFill; + ctx.strokeStyle = this.colors.bodyStroke; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(neck.x - 2, neck.y); + ctx.lineTo(neck.x + 5, headY + 8); + ctx.lineTo(neck.x + 12, headY); + ctx.lineTo(neck.x + neck.width - 12, headY); + ctx.lineTo(neck.x + neck.width - 5, headY + 8); + ctx.lineTo(neck.x + neck.width + 2, neck.y); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + + _drawBody(neck, _w) { + const ctx = this.ctx; + const bodyY = neck.y + neck.height; + const bh = this.bodyHeight; + const centerX = neck.x + neck.width / 2; + + ctx.fillStyle = this.colors.bodyFill; + ctx.strokeStyle = this.colors.bodyStroke; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(neck.x - 2, bodyY); + ctx.quadraticCurveTo( + neck.x + neck.width * 0.15, + bodyY + bh * 0.8, + centerX - neck.width * 0.05, + bodyY + bh + ); + ctx.quadraticCurveTo(centerX, bodyY + bh * 0.7, centerX + neck.width * 0.05, bodyY + bh); + ctx.quadraticCurveTo( + neck.x + neck.width * 0.85, + bodyY + bh * 0.8, + neck.x + neck.width + 2, + bodyY + ); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + // Sound hole + ctx.fillStyle = this.colors.background; + ctx.beginPath(); + ctx.arc(centerX, bodyY + bh * 0.4, Math.min(bh * 0.25, neck.width * 0.12), 0, Math.PI * 2); + ctx.fill(); + } + + _drawFrets(neck) { + const ctx = this.ctx; + ctx.strokeStyle = this.colors.fretWire; + + for (let f = 0; f <= this.numFrets; f++) { + const y = this._getFretY(f); + ctx.lineWidth = f === 0 ? 3 : 1; + ctx.beginPath(); + ctx.moveTo(neck.x, y); + ctx.lineTo(neck.x + neck.width, y); + ctx.stroke(); } + } - // ======================================================================== - // RENDERING - // ======================================================================== - - redraw() { - const { canvas, ctx } = this; - const w = canvas.width; - const h = canvas.height; - const neck = this._getNeckBounds(); - - // Clear - ctx.fillStyle = this.colors.background; - ctx.fillRect(0, 0, w, h); - - // Draw simplified guitar head (top) - this._drawHead(neck, w); - - // Draw neck background - ctx.fillStyle = this.colors.neck; - ctx.fillRect(neck.x, neck.y, neck.width, neck.height); - - // Draw simplified body (bottom) - this._drawBody(neck, w); + _drawNut(neck) { + const ctx = this.ctx; + const y = this._getFretY(0); + ctx.fillStyle = this.colors.nut; + ctx.fillRect(neck.x, y - 2, neck.width, 5); + } - // Draw fret markers - this._drawFretMarkers(neck); + _drawFretMarkers(neck) { + const ctx = this.ctx; + const centerX = neck.x + neck.width / 2; - // Draw frets (horizontal lines) - this._drawFrets(neck); + for (const fretNum of this.markerFrets) { + if (fretNum > this.numFrets) continue; - // Draw nut (horizontal bar at top) - this._drawNut(neck); + const y1 = this._getFretY(fretNum - 1); + const y2 = this._getFretY(fretNum); + const midY = (y1 + y2) / 2; + const radius = 3; - // Draw inactive zones per string - this._drawInactiveZones(neck); + ctx.fillStyle = this.colors.markerDot; - // Draw strings (vertical lines) - this._drawStrings(neck); - - // Draw fret numbers (left margin) - this._drawFretNumbers(neck); - - // Draw draggable handles - this._drawHandles(neck); - } - - _drawHead(neck, _w) { - const ctx = this.ctx; - const headY = this.topMargin; - - // Simplified head shape (horizontal, at top) - ctx.fillStyle = this.colors.bodyFill; - ctx.strokeStyle = this.colors.bodyStroke; - ctx.lineWidth = 1.5; + if (this.doubleMarkerFrets.includes(fretNum)) { + const offset = neck.width * 0.2; ctx.beginPath(); - ctx.moveTo(neck.x - 2, neck.y); - ctx.lineTo(neck.x + 5, headY + 8); - ctx.lineTo(neck.x + 12, headY); - ctx.lineTo(neck.x + neck.width - 12, headY); - ctx.lineTo(neck.x + neck.width - 5, headY + 8); - ctx.lineTo(neck.x + neck.width + 2, neck.y); - ctx.closePath(); + ctx.arc(centerX - offset, midY, radius, 0, Math.PI * 2); ctx.fill(); - ctx.stroke(); - } - - _drawBody(neck, _w) { - const ctx = this.ctx; - const bodyY = neck.y + neck.height; - const bh = this.bodyHeight; - const centerX = neck.x + neck.width / 2; - - ctx.fillStyle = this.colors.bodyFill; - ctx.strokeStyle = this.colors.bodyStroke; - ctx.lineWidth = 1.5; ctx.beginPath(); - ctx.moveTo(neck.x - 2, bodyY); - ctx.quadraticCurveTo(neck.x + neck.width * 0.15, bodyY + bh * 0.8, centerX - neck.width * 0.05, bodyY + bh); - ctx.quadraticCurveTo(centerX, bodyY + bh * 0.7, centerX + neck.width * 0.05, bodyY + bh); - ctx.quadraticCurveTo(neck.x + neck.width * 0.85, bodyY + bh * 0.8, neck.x + neck.width + 2, bodyY); - ctx.closePath(); + ctx.arc(centerX + offset, midY, radius, 0, Math.PI * 2); ctx.fill(); - ctx.stroke(); - - // Sound hole - ctx.fillStyle = this.colors.background; + } else { ctx.beginPath(); - ctx.arc(centerX, bodyY + bh * 0.4, Math.min(bh * 0.25, neck.width * 0.12), 0, Math.PI * 2); + ctx.arc(centerX, midY, radius, 0, Math.PI * 2); ctx.fill(); + } } - - _drawFrets(neck) { - const ctx = this.ctx; - ctx.strokeStyle = this.colors.fretWire; - - for (let f = 0; f <= this.numFrets; f++) { - const y = this._getFretY(f); - ctx.lineWidth = (f === 0) ? 3 : 1; - ctx.beginPath(); - ctx.moveTo(neck.x, y); - ctx.lineTo(neck.x + neck.width, y); - ctx.stroke(); - } - } - - _drawNut(neck) { - const ctx = this.ctx; - const y = this._getFretY(0); - ctx.fillStyle = this.colors.nut; - ctx.fillRect(neck.x, y - 2, neck.width, 5); - } - - _drawFretMarkers(neck) { - const ctx = this.ctx; - const centerX = neck.x + neck.width / 2; - - for (const fretNum of this.markerFrets) { - if (fretNum > this.numFrets) continue; - - const y1 = this._getFretY(fretNum - 1); - const y2 = this._getFretY(fretNum); - const midY = (y1 + y2) / 2; - const radius = 3; - - ctx.fillStyle = this.colors.markerDot; - - if (this.doubleMarkerFrets.includes(fretNum)) { - const offset = neck.width * 0.2; - ctx.beginPath(); - ctx.arc(centerX - offset, midY, radius, 0, Math.PI * 2); - ctx.fill(); - ctx.beginPath(); - ctx.arc(centerX + offset, midY, radius, 0, Math.PI * 2); - ctx.fill(); - } else { - ctx.beginPath(); - ctx.arc(centerX, midY, radius, 0, Math.PI * 2); - ctx.fill(); - } - } - } - - _drawInactiveZones(neck) { - const ctx = this.ctx; - - const spacing = neck.width / (this.numStrings + 1); - - for (let i = 0; i < this.numStrings; i++) { - const fretCount = this.fretsPerString[i]; - if (fretCount >= this.numFrets) continue; - - const stringX = this._getStringX(i); - const startY = this._getFretY(fretCount); - const endY = neck.y + neck.height; - - // Draw semi-transparent overlay on inactive zone - ctx.fillStyle = this.colors.inactiveZone; - ctx.fillRect(stringX - spacing * 0.4, startY, spacing * 0.8, endY - startY); - - // Dashed border at the boundary - ctx.strokeStyle = this.colors.handle; - ctx.lineWidth = 1; - ctx.setLineDash([3, 3]); - ctx.beginPath(); - ctx.moveTo(stringX - spacing * 0.4, startY); - ctx.lineTo(stringX + spacing * 0.4, startY); - ctx.stroke(); - ctx.setLineDash([]); - } - } - - _drawStrings(neck) { - const ctx = this.ctx; - - for (let i = 0; i < this.numStrings; i++) { - const x = this._getStringX(i); - const fretCount = this.fretsPerString[i]; - const endY = this._getFretY(fretCount); - - // Active portion of string (from head through nut to fretCount) - ctx.strokeStyle = this.colors.string; - ctx.lineWidth = 1 + (this.numStrings - 1 - i) * 0.4; - ctx.beginPath(); - ctx.moveTo(x, neck.y - this.headHeight + 10); - ctx.lineTo(x, endY); - ctx.stroke(); - - // Inactive portion (dimmed) - if (fretCount < this.numFrets) { - ctx.strokeStyle = this.colors.string; - ctx.globalAlpha = 0.2; - ctx.beginPath(); - ctx.moveTo(x, endY); - ctx.lineTo(x, neck.y + neck.height + this.bodyHeight * 0.3); - ctx.stroke(); - ctx.globalAlpha = 1.0; - } else { - ctx.beginPath(); - ctx.moveTo(x, endY); - ctx.lineTo(x, neck.y + neck.height + this.bodyHeight * 0.3); - ctx.stroke(); - } - } - } - - _drawFretNumbers(neck) { - const ctx = this.ctx; - ctx.fillStyle = this.colors.fretNumber; - ctx.font = '8px monospace'; - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - - // Show fret numbers at certain intervals - const step = this.numFrets <= 12 ? 1 : (this.numFrets <= 24 ? 2 : 3); - for (let f = step; f <= this.numFrets; f += step) { - const y1 = this._getFretY(f - 1); - const y2 = this._getFretY(f); - const midY = (y1 + y2) / 2; - ctx.fillText(f.toString(), neck.x - 6, midY); - } + } + + _drawInactiveZones(neck) { + const ctx = this.ctx; + + const spacing = neck.width / (this.numStrings + 1); + + for (let i = 0; i < this.numStrings; i++) { + const fretCount = this.fretsPerString[i]; + if (fretCount >= this.numFrets) continue; + + const stringX = this._getStringX(i); + const startY = this._getFretY(fretCount); + const endY = neck.y + neck.height; + + // Draw semi-transparent overlay on inactive zone + ctx.fillStyle = this.colors.inactiveZone; + ctx.fillRect(stringX - spacing * 0.4, startY, spacing * 0.8, endY - startY); + + // Dashed border at the boundary + ctx.strokeStyle = this.colors.handle; + ctx.lineWidth = 1; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(stringX - spacing * 0.4, startY); + ctx.lineTo(stringX + spacing * 0.4, startY); + ctx.stroke(); + ctx.setLineDash([]); } - - _drawHandles(_neck) { - // Handles are drawn for both fretted and fretless. On fretless - // instruments the "frets" are semitone positions above the open - // string — the editor metaphor is identical. - const ctx = this.ctx; - const handleRadius = 7; - - for (let i = 0; i < this.numStrings; i++) { - const x = this._getStringX(i); - const fretCount = this.fretsPerString[i]; - const y = this._getFretY(fretCount); - - const isHovered = this.hoveredString === i; - const isDragging = this.dragging && this.dragging.stringIndex === i; - - // Handle circle - ctx.fillStyle = (isHovered || isDragging) ? this.colors.handleHover : this.colors.handle; - ctx.globalAlpha = isDragging ? 1.0 : (isHovered ? 0.9 : 0.75); - ctx.beginPath(); - ctx.arc(x, y, handleRadius, 0, Math.PI * 2); - ctx.fill(); - ctx.globalAlpha = 1.0; - - // Fret number in handle - ctx.fillStyle = this.colors.handleText; - ctx.font = 'bold 8px monospace'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(fretCount.toString(), x, y); - } + } + + _drawStrings(neck) { + const ctx = this.ctx; + + for (let i = 0; i < this.numStrings; i++) { + const x = this._getStringX(i); + const fretCount = this.fretsPerString[i]; + const endY = this._getFretY(fretCount); + + // Active portion of string (from head through nut to fretCount) + ctx.strokeStyle = this.colors.string; + ctx.lineWidth = 1 + (this.numStrings - 1 - i) * 0.4; + ctx.beginPath(); + ctx.moveTo(x, neck.y - this.headHeight + 10); + ctx.lineTo(x, endY); + ctx.stroke(); + + // Inactive portion (dimmed) + if (fretCount < this.numFrets) { + ctx.strokeStyle = this.colors.string; + ctx.globalAlpha = 0.2; + ctx.beginPath(); + ctx.moveTo(x, endY); + ctx.lineTo(x, neck.y + neck.height + this.bodyHeight * 0.3); + ctx.stroke(); + ctx.globalAlpha = 1.0; + } else { + ctx.beginPath(); + ctx.moveTo(x, endY); + ctx.lineTo(x, neck.y + neck.height + this.bodyHeight * 0.3); + ctx.stroke(); + } } - - // ======================================================================== - // INTERACTION - // ======================================================================== - - _getCanvasPos(e) { - const rect = this.canvas.getBoundingClientRect(); - const scaleX = this.canvas.width / rect.width; - const scaleY = this.canvas.height / rect.height; - return { - x: (e.clientX - rect.left) * scaleX, - y: (e.clientY - rect.top) * scaleY - }; + } + + _drawFretNumbers(neck) { + const ctx = this.ctx; + ctx.fillStyle = this.colors.fretNumber; + ctx.font = '8px monospace'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + + // Show fret numbers at certain intervals + const step = this.numFrets <= 12 ? 1 : this.numFrets <= 24 ? 2 : 3; + for (let f = step; f <= this.numFrets; f += step) { + const y1 = this._getFretY(f - 1); + const y2 = this._getFretY(f); + const midY = (y1 + y2) / 2; + ctx.fillText(f.toString(), neck.x - 6, midY); } - - _onMouseDown(e) { - const pos = this._getCanvasPos(e); - const stringIdx = this._getStringAtX(pos.x); - if (stringIdx < 0) return; - - // Check if near the handle - const handleX = this._getStringX(stringIdx); - const handleY = this._getFretY(this.fretsPerString[stringIdx]); - const dist = Math.sqrt((pos.x - handleX) ** 2 + (pos.y - handleY) ** 2); - - if (dist < 14) { - this.dragging = { stringIndex: stringIdx }; - this.canvas.style.cursor = 'grabbing'; - e.preventDefault(); - } + } + + _drawHandles(_neck) { + // Handles are drawn for both fretted and fretless. On fretless + // instruments the "frets" are semitone positions above the open + // string — the editor metaphor is identical. + const ctx = this.ctx; + const handleRadius = 7; + + for (let i = 0; i < this.numStrings; i++) { + const x = this._getStringX(i); + const fretCount = this.fretsPerString[i]; + const y = this._getFretY(fretCount); + + const isHovered = this.hoveredString === i; + const isDragging = this.dragging && this.dragging.stringIndex === i; + + // Handle circle + ctx.fillStyle = isHovered || isDragging ? this.colors.handleHover : this.colors.handle; + ctx.globalAlpha = isDragging ? 1.0 : isHovered ? 0.9 : 0.75; + ctx.beginPath(); + ctx.arc(x, y, handleRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1.0; + + // Fret number in handle + ctx.fillStyle = this.colors.handleText; + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(fretCount.toString(), x, y); } - - _onMouseMove(e) { - const pos = this._getCanvasPos(e); - - if (this.dragging) { - const newFret = this._yToFret(pos.y); - const idx = this.dragging.stringIndex; - if (newFret !== this.fretsPerString[idx]) { - this.fretsPerString[idx] = newFret; - this.redraw(); - if (this.onChange) this.onChange(this.getFretsPerString()); - } - return; - } - - // Hover detection - const stringIdx = this._getStringAtX(pos.x); - let newHover = -1; - if (stringIdx >= 0) { - const handleX = this._getStringX(stringIdx); - const handleY = this._getFretY(this.fretsPerString[stringIdx]); - const dist = Math.sqrt((pos.x - handleX) ** 2 + (pos.y - handleY) ** 2); - if (dist < 14) { - newHover = stringIdx; - this.canvas.style.cursor = 'grab'; - } else { - this.canvas.style.cursor = 'default'; - } - } else { - this.canvas.style.cursor = 'default'; - } - - if (newHover !== this.hoveredString) { - this.hoveredString = newHover; - this.redraw(); - } + } + + // ======================================================================== + // INTERACTION + // ======================================================================== + + _getCanvasPos(e) { + const rect = this.canvas.getBoundingClientRect(); + const scaleX = this.canvas.width / rect.width; + const scaleY = this.canvas.height / rect.height; + return { + x: (e.clientX - rect.left) * scaleX, + y: (e.clientY - rect.top) * scaleY + }; + } + + _onMouseDown(e) { + const pos = this._getCanvasPos(e); + const stringIdx = this._getStringAtX(pos.x); + if (stringIdx < 0) return; + + // Check if near the handle + const handleX = this._getStringX(stringIdx); + const handleY = this._getFretY(this.fretsPerString[stringIdx]); + const dist = Math.sqrt((pos.x - handleX) ** 2 + (pos.y - handleY) ** 2); + + if (dist < 14) { + this.dragging = { stringIndex: stringIdx }; + this.canvas.style.cursor = 'grabbing'; + e.preventDefault(); } + } - _onMouseUp() { - if (this.dragging) { - this.dragging = null; - this.canvas.style.cursor = 'default'; - this.redraw(); - } - } + _onMouseMove(e) { + const pos = this._getCanvasPos(e); - _onMouseLeave() { - if (this.dragging) { - this.dragging = null; - this.canvas.style.cursor = 'default'; - } - if (this.hoveredString >= 0) { - this.hoveredString = -1; - this.redraw(); - } + if (this.dragging) { + const newFret = this._yToFret(pos.y); + const idx = this.dragging.stringIndex; + if (newFret !== this.fretsPerString[idx]) { + this.fretsPerString[idx] = newFret; + this.redraw(); + if (this.onChange) this.onChange(this.getFretsPerString()); + } + return; } - // Touch events - _onTouchStart(e) { - if (e.touches.length !== 1) return; - const touch = e.touches[0]; - this._onMouseDown({ clientX: touch.clientX, clientY: touch.clientY, preventDefault: () => e.preventDefault() }); + // Hover detection + const stringIdx = this._getStringAtX(pos.x); + let newHover = -1; + if (stringIdx >= 0) { + const handleX = this._getStringX(stringIdx); + const handleY = this._getFretY(this.fretsPerString[stringIdx]); + const dist = Math.sqrt((pos.x - handleX) ** 2 + (pos.y - handleY) ** 2); + if (dist < 14) { + newHover = stringIdx; + this.canvas.style.cursor = 'grab'; + } else { + this.canvas.style.cursor = 'default'; + } + } else { + this.canvas.style.cursor = 'default'; } - _onTouchMove(e) { - if (!this.dragging || e.touches.length !== 1) return; - e.preventDefault(); - const touch = e.touches[0]; - this._onMouseMove({ clientX: touch.clientX, clientY: touch.clientY }); + if (newHover !== this.hoveredString) { + this.hoveredString = newHover; + this.redraw(); } + } - _onTouchEnd() { - this._onMouseUp(); + _onMouseUp() { + if (this.dragging) { + this.dragging = null; + this.canvas.style.cursor = 'default'; + this.redraw(); } + } - // ======================================================================== - // RESIZE - // ======================================================================== - - resize(width, height) { - this.canvas.width = width; - this.canvas.height = height; - this.redraw(); + _onMouseLeave() { + if (this.dragging) { + this.dragging = null; + this.canvas.style.cursor = 'default'; } - - // ======================================================================== - // CLEANUP - // ======================================================================== - - destroy() { - this.canvas.removeEventListener('mousedown', this._onMouseDown); - this.canvas.removeEventListener('mousemove', this._onMouseMove); - this.canvas.removeEventListener('mouseup', this._onMouseUp); - this.canvas.removeEventListener('mouseleave', this._onMouseLeave); - this.canvas.removeEventListener('touchstart', this._onTouchStart); - this.canvas.removeEventListener('touchmove', this._onTouchMove); - this.canvas.removeEventListener('touchend', this._onTouchEnd); + if (this.hoveredString >= 0) { + this.hoveredString = -1; + this.redraw(); } + } + + // Touch events + _onTouchStart(e) { + if (e.touches.length !== 1) return; + const touch = e.touches[0]; + this._onMouseDown({ + clientX: touch.clientX, + clientY: touch.clientY, + preventDefault: () => e.preventDefault() + }); + } + + _onTouchMove(e) { + if (!this.dragging || e.touches.length !== 1) return; + e.preventDefault(); + const touch = e.touches[0]; + this._onMouseMove({ clientX: touch.clientX, clientY: touch.clientY }); + } + + _onTouchEnd() { + this._onMouseUp(); + } + + // ======================================================================== + // RESIZE + // ======================================================================== + + resize(width, height) { + this.canvas.width = width; + this.canvas.height = height; + this.redraw(); + } + + // ======================================================================== + // CLEANUP + // ======================================================================== + + destroy() { + this.canvas.removeEventListener('mousedown', this._onMouseDown); + this.canvas.removeEventListener('mousemove', this._onMouseMove); + this.canvas.removeEventListener('mouseup', this._onMouseUp); + this.canvas.removeEventListener('mouseleave', this._onMouseLeave); + this.canvas.removeEventListener('touchstart', this._onTouchStart); + this.canvas.removeEventListener('touchmove', this._onTouchMove); + this.canvas.removeEventListener('touchend', this._onTouchEnd); + } } // ============================================================================ // EXPORT // ============================================================================ if (typeof module !== 'undefined' && module.exports) { - module.exports = NeckDiagramConfig; + module.exports = NeckDiagramConfig; } if (typeof window !== 'undefined') { - window.NeckDiagramConfig = NeckDiagramConfig; + window.NeckDiagramConfig = NeckDiagramConfig; } diff --git a/public/js/features/NetworkScanModal.js b/public/js/features/NetworkScanModal.js index 91a17fdbf..5ded8419b 100644 --- a/public/js/features/NetworkScanModal.js +++ b/public/js/features/NetworkScanModal.js @@ -14,127 +14,129 @@ // ============================================================================ class NetworkScanModal { - constructor(eventBus) { - this.eventBus = eventBus || window.eventBus || null; - this.logger = window.logger || console; - - this.container = null; - this.isOpen = false; - this.scanning = false; - this.availableDevices = []; - this.connectedDevices = []; + constructor(eventBus) { + this.eventBus = eventBus || window.eventBus || null; + this.logger = window.logger || console; + + this.container = null; + this.isOpen = false; + this.scanning = false; + this.availableDevices = []; + this.connectedDevices = []; + + this.setupEventListeners(); + + this.logger.info('NetworkScanModal', '✓ Modal initialized v1.1.0 (i18n)'); + } + + // Helper for translations + t(key, params) { + return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + } + + // ======================================================================== + // EVENTS + // ======================================================================== + + setupEventListeners() { + if (!this.eventBus) return; + + this._eventUnsubs = [ + this.eventBus.on('network:scanned', (data) => this.handleScanComplete(data)), + this.eventBus.on('network:connected_list', (data) => this.handleConnectedList(data)), + this.eventBus.on('network:connected', (data) => this.handleDeviceConnected(data)), + this.eventBus.on('network:disconnected', (data) => this.handleDeviceDisconnected(data)), + this.eventBus.on('network:scan_error', (data) => this.handleScanError(data)) + ]; + + if (typeof i18n !== 'undefined') { + this._localeUnsubscribe = i18n.onLocaleChange(() => this.updateModalContent()); + } - this.setupEventListeners(); + this.logger.debug('NetworkScanModal', 'Event listeners configured'); + } - this.logger.info('NetworkScanModal', '✓ Modal initialized v1.1.0 (i18n)'); + destroy() { + this.close(); + if (this._eventUnsubs) { + this._eventUnsubs.forEach((unsub) => { + if (typeof unsub === 'function') unsub(); + }); + this._eventUnsubs = []; } - - // Helper for translations - t(key, params) { - return typeof i18n !== 'undefined' ? i18n.t(key, params) : key; + } + + // ======================================================================== + // MODAL DISPLAY + // ======================================================================== + + /** + * Open the modal and start the scan + */ + open() { + if (this.isOpen) { + this.logger.warn('NetworkScanModal', 'Modal already open'); + return; } - // ======================================================================== - // EVENTS - // ======================================================================== + this.isOpen = true; + this.availableDevices = []; + this.connectedDevices = []; - setupEventListeners() { - if (!this.eventBus) return; + this.createModal(); + this.loadConnectedDevices(); + this.startScan(); - this._eventUnsubs = [ - this.eventBus.on('network:scanned', (data) => this.handleScanComplete(data)), - this.eventBus.on('network:connected_list', (data) => this.handleConnectedList(data)), - this.eventBus.on('network:connected', (data) => this.handleDeviceConnected(data)), - this.eventBus.on('network:disconnected', (data) => this.handleDeviceDisconnected(data)), - this.eventBus.on('network:scan_error', (data) => this.handleScanError(data)), - ]; + this.logger.info('NetworkScanModal', 'Modal opened'); + } - if (typeof i18n !== 'undefined') { - this._localeUnsubscribe = i18n.onLocaleChange(() => this.updateModalContent()); - } + /** + * Close the modal + */ + close() { + if (!this.isOpen) return; - this.logger.debug('NetworkScanModal', 'Event listeners configured'); - } + this.isOpen = false; + this.scanning = false; - destroy() { - this.close(); - if (this._eventUnsubs) { - this._eventUnsubs.forEach(unsub => { if (typeof unsub === 'function') unsub(); }); - this._eventUnsubs = []; - } + if (this._localeUnsubscribe) { + this._localeUnsubscribe(); + this._localeUnsubscribe = null; } - // ======================================================================== - // MODAL DISPLAY - // ======================================================================== - - /** - * Open the modal and start the scan - */ - open() { - if (this.isOpen) { - this.logger.warn('NetworkScanModal', 'Modal already open'); - return; - } - - this.isOpen = true; - this.availableDevices = []; - this.connectedDevices = []; - - this.createModal(); - this.loadConnectedDevices(); - this.startScan(); - - this.logger.info('NetworkScanModal', 'Modal opened'); + if (this.container) { + this.container.remove(); + this.container = null; } - /** - * Close the modal - */ - close() { - if (!this.isOpen) return; - - this.isOpen = false; - this.scanning = false; - - if (this._localeUnsubscribe) { - this._localeUnsubscribe(); - this._localeUnsubscribe = null; - } - - if (this.container) { - this.container.remove(); - this.container = null; - } + this.logger.info('NetworkScanModal', 'Modal closed'); + } - this.logger.info('NetworkScanModal', 'Modal closed'); + /** + * Build the modal DOM + */ + createModal() { + // Remove the old modal if it exists + if (this.container) { + this.container.remove(); } - /** - * Build the modal DOM - */ - createModal() { - // Remove the old modal if it exists - if (this.container) { - this.container.remove(); - } - - // Create the new modal - this.container = document.createElement('div'); - this.container.className = 'modal-overlay network-scan-modal'; - this.container.innerHTML = this.renderModalContent(); + // Create the new modal + this.container = document.createElement('div'); + this.container.className = 'modal-overlay network-scan-modal'; + this.container.innerHTML = this.renderModalContent(); - document.body.appendChild(this.container); + document.body.appendChild(this.container); - // Attach events - this.attachModalEvents(); - } + // Attach events + this.attachModalEvents(); + } - /** - * Render the modal content - */ - renderModalContent() { - return ` + /** + * Render the modal content + */ + renderModalContent() { + return ` `; - } - - /** - * Render the available devices list - */ - renderAvailableDevices() { - if (this.scanning) { - return ` + } + + /** + * Render the available devices list + */ + renderAvailableDevices() { + if (this.scanning) { + return `

${this.t('network.searchingDevices')}

${this.t('network.operationMayTakeTime')}

`; - } + } - if (this.availableDevices.length === 0) { - return ` + if (this.availableDevices.length === 0) { + return `
🔍

${this.t('network.noDeviceDetected')}

${this.t('network.clickToScan')}

`; - } + } - return ` + return `
- ${this.availableDevices.map(device => this.renderAvailableDevice(device)).join('')} + ${this.availableDevices.map((device) => this.renderAvailableDevice(device)).join('')}
`; - } + } - /** - * Render an available device - */ - renderAvailableDevice(device) { - const deviceName = escapeHtml(device.name || this.t('network.networkInstrument')); - const deviceIp = device.ip || device.address || this.t('network.unknownIP'); - const devicePort = device.port || ''; + /** + * Render an available device + */ + renderAvailableDevice(device) { + const deviceName = escapeHtml(device.name || this.t('network.networkInstrument')); + const deviceIp = device.ip || device.address || this.t('network.unknownIP'); + const devicePort = device.port || ''; - return ` + return `
🌐
@@ -280,32 +286,32 @@ class NetworkScanModal {
`; + } + + /** + * Render the connected devices list + */ + renderConnectedDevices() { + if (this.connectedDevices.length === 0) { + return `

${this.t('network.noDeviceConnected')}

`; } - /** - * Render the connected devices list - */ - renderConnectedDevices() { - if (this.connectedDevices.length === 0) { - return `

${this.t('network.noDeviceConnected')}

`; - } - - return ` + return `
- ${this.connectedDevices.map(device => this.renderConnectedDevice(device)).join('')} + ${this.connectedDevices.map((device) => this.renderConnectedDevice(device)).join('')}
`; - } + } - /** - * Render a connected device - */ - renderConnectedDevice(device) { - const deviceName = escapeHtml(device.name || device.ip); - const deviceIp = device.ip || device.address; - const devicePort = device.port || ''; + /** + * Render a connected device + */ + renderConnectedDevice(device) { + const deviceName = escapeHtml(device.name || device.ip); + const deviceIp = device.ip || device.address; + const devicePort = device.port || ''; - return ` + return `
@@ -324,179 +330,185 @@ class NetworkScanModal {
`; - } - - // ======================================================================== - // DOM EVENTS - // ======================================================================== - - /** - * Attach the modal events - */ - attachModalEvents() { - if (!this.container) return; - - // Use event delegation on the container - // to handle ALL clicks (close, scan, connect, disconnect) - this.container.addEventListener('click', (e) => { - const action = e.target.dataset.action; - - // Close - if (action === 'close' || e.target === this.container) { - this.close(); - return; - } - - // Scan - if (action === 'scan') { - this.startScan(); - return; - } - - // Connexion manuelle - if (action === 'connect-manual') { - this.connectManual(); - return; - } - - // Connexion device - if (action === 'connect') { - const deviceIp = e.target.dataset.deviceIp; - const devicePort = e.target.dataset.devicePort; - const deviceName = e.target.dataset.deviceName; - if (deviceIp) this.connectDevice(deviceIp, devicePort, deviceName); - return; - } - - // Device disconnect - if (action === 'disconnect') { - const deviceIp = e.target.dataset.deviceIp; - const deviceName = e.target.dataset.deviceName || `Appareil ${deviceIp}`; - if (deviceIp) this.showDisconnectModal(deviceIp, deviceName); - return; - } - }); - - // Enter on the IP field - const manualIpInput = this.container.querySelector('#manualIp'); - if (manualIpInput) { - manualIpInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - this.connectManual(); - } - }); - } - } - - // ======================================================================== - // ACTIONS - // ======================================================================== - - /** - * Start the network scan - */ - startScan() { - if (this.scanning) { - this.logger.warn('NetworkScanModal', 'Scan already in progress'); - return; - } + } - this.scanning = true; - this.availableDevices = []; + // ======================================================================== + // DOM EVENTS + // ======================================================================== - // Check whether full scan is enabled - const fullScanCheckbox = this.container ? this.container.querySelector('#fullScanCheckbox') : null; - const fullScan = fullScanCheckbox ? fullScanCheckbox.checked : false; + /** + * Attach the modal events + */ + attachModalEvents() { + if (!this.container) return; - this.updateModalContent(); + // Use event delegation on the container + // to handle ALL clicks (close, scan, connect, disconnect) + this.container.addEventListener('click', (e) => { + const action = e.target.dataset.action; - this.logger.info('NetworkScanModal', `Starting network scan (fullScan: ${fullScan})`); + // Close + if (action === 'close' || e.target === this.container) { + this.close(); + return; + } - if (this.eventBus) { - this.eventBus.emit('network:scan_requested', { fullScan }); - } else { - this.logger.error('NetworkScanModal', 'EventBus not available'); - this.scanning = false; - this.updateModalContent(); + // Scan + if (action === 'scan') { + this.startScan(); + return; + } + + // Connexion manuelle + if (action === 'connect-manual') { + this.connectManual(); + return; + } + + // Connexion device + if (action === 'connect') { + const deviceIp = e.target.dataset.deviceIp; + const devicePort = e.target.dataset.devicePort; + const deviceName = e.target.dataset.deviceName; + if (deviceIp) this.connectDevice(deviceIp, devicePort, deviceName); + return; + } + + // Device disconnect + if (action === 'disconnect') { + const deviceIp = e.target.dataset.deviceIp; + const deviceName = e.target.dataset.deviceName || `Appareil ${deviceIp}`; + if (deviceIp) this.showDisconnectModal(deviceIp, deviceName); + return; + } + }); + + // Enter on the IP field + const manualIpInput = this.container.querySelector('#manualIp'); + if (manualIpInput) { + manualIpInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this.connectManual(); } + }); + } + } + + // ======================================================================== + // ACTIONS + // ======================================================================== + + /** + * Start the network scan + */ + startScan() { + if (this.scanning) { + this.logger.warn('NetworkScanModal', 'Scan already in progress'); + return; } - /** - * Connecte manuellement via IP - */ - connectManual() { - const ipInput = this.container.querySelector('#manualIp'); - const portInput = this.container.querySelector('#manualPort'); + this.scanning = true; + this.availableDevices = []; - if (!ipInput) { - this.logger.error('NetworkScanModal', 'IP input not found'); - return; - } + // Check whether full scan is enabled + const fullScanCheckbox = this.container + ? this.container.querySelector('#fullScanCheckbox') + : null; + const fullScan = fullScanCheckbox ? fullScanCheckbox.checked : false; - const ip = ipInput.value.trim(); - const port = portInput ? portInput.value.trim() : '5004'; + this.updateModalContent(); - // IP address validation - if (!ip) { - alert(`⚠️ ${this.t('network.invalidIP.empty')}`); - ipInput.focus(); - return; - } + this.logger.info('NetworkScanModal', `Starting network scan (fullScan: ${fullScan})`); - // Regex to validate the IP - const ipPattern = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; - if (!ipPattern.test(ip)) { - alert(`⚠️ ${this.t('network.invalidIP.format')}`); - ipInput.focus(); - return; - } + if (this.eventBus) { + this.eventBus.emit('network:scan_requested', { fullScan }); + } else { + this.logger.error('NetworkScanModal', 'EventBus not available'); + this.scanning = false; + this.updateModalContent(); + } + } + + /** + * Connecte manuellement via IP + */ + connectManual() { + const ipInput = this.container.querySelector('#manualIp'); + const portInput = this.container.querySelector('#manualPort'); + + if (!ipInput) { + this.logger.error('NetworkScanModal', 'IP input not found'); + return; + } - this.logger.info('NetworkScanModal', `Manual connection to: ${ip}:${port}`); + const ip = ipInput.value.trim(); + const port = portInput ? portInput.value.trim() : '5004'; - // Connect the device - const deviceName = `${this.t('network.networkInstrument')} (${ip})`; - this.connectDevice(ip, port, deviceName); + // IP address validation + if (!ip) { + alert(`⚠️ ${this.t('network.invalidIP.empty')}`); + ipInput.focus(); + return; + } - // Clear fields after connection - ipInput.value = ''; - if (portInput) portInput.value = '5004'; + // Regex to validate the IP + const ipPattern = + /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + if (!ipPattern.test(ip)) { + alert(`⚠️ ${this.t('network.invalidIP.format')}`); + ipInput.focus(); + return; } - /** - * Load the connected devices list - */ - loadConnectedDevices() { - this.logger.debug('NetworkScanModal', 'Loading connected devices'); + this.logger.info('NetworkScanModal', `Manual connection to: ${ip}:${port}`); - if (this.eventBus) { - this.eventBus.emit('network:connected_requested'); - } - } + // Connect the device + const deviceName = `${this.t('network.networkInstrument')} (${ip})`; + this.connectDevice(ip, port, deviceName); - /** - * Connect a device - */ - connectDevice(deviceIp, devicePort, deviceName) { - this.logger.info('NetworkScanModal', `Connecting device: ${deviceIp}${devicePort ? ':' + devicePort : ''}`); - - if (this.eventBus) { - this.eventBus.emit('network:connect_requested', { - ip: deviceIp, - port: devicePort || '', - address: deviceIp, - name: deviceName - }); - } - } + // Clear fields after connection + ipInput.value = ''; + if (portInput) portInput.value = '5004'; + } + + /** + * Load the connected devices list + */ + loadConnectedDevices() { + this.logger.debug('NetworkScanModal', 'Loading connected devices'); - /** - * Show the disconnect-confirmation modal - */ - showDisconnectModal(deviceIp, deviceName) { - // Create the modal - const modalOverlay = document.createElement('div'); - modalOverlay.className = 'disconnect-modal-overlay'; - modalOverlay.innerHTML = ` + if (this.eventBus) { + this.eventBus.emit('network:connected_requested'); + } + } + + /** + * Connect a device + */ + connectDevice(deviceIp, devicePort, deviceName) { + this.logger.info( + 'NetworkScanModal', + `Connecting device: ${deviceIp}${devicePort ? ':' + devicePort : ''}` + ); + + if (this.eventBus) { + this.eventBus.emit('network:connect_requested', { + ip: deviceIp, + port: devicePort || '', + address: deviceIp, + name: deviceName + }); + } + } + + /** + * Show the disconnect-confirmation modal + */ + showDisconnectModal(deviceIp, deviceName) { + // Create the modal + const modalOverlay = document.createElement('div'); + modalOverlay.className = 'disconnect-modal-overlay'; + modalOverlay.innerHTML = `

⚠️ ${this.t('network.disconnect.title')}

@@ -518,149 +530,157 @@ class NetworkScanModal {
`; - // Ajouter au DOM - document.body.appendChild(modalOverlay); - - // Handle clicks - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay || e.target.dataset.action === 'cancel') { - modalOverlay.remove(); - } else if (e.target.dataset.action === 'confirm') { - modalOverlay.remove(); - this.disconnectDevice(deviceIp); - } - }); - - // Focus the cancel button by default - setTimeout(() => { - const cancelBtn = modalOverlay.querySelector('.btn-cancel'); - if (cancelBtn) cancelBtn.focus(); - }, 100); - } - - /** - * Disconnect a device (without confirmation) - */ - disconnectDevice(deviceIp) { - this.logger.info('NetworkScanModal', `Disconnecting device: ${deviceIp}`); - - if (this.eventBus) { - this.eventBus.emit('network:disconnect_requested', { - ip: deviceIp, - address: deviceIp - }); - } - } - - // ======================================================================== - // HANDLERS - // ======================================================================== - - /** - * Handle scan completion - */ - handleScanComplete(data) { - this.scanning = false; - this.availableDevices = data.devices || []; - - this.logger.info('NetworkScanModal', `Scan complete: ${this.availableDevices.length} devices found`); - - this.updateModalContent(); - } - - /** - * Handle the connected devices list - */ - handleConnectedList(data) { - this.connectedDevices = data.devices || []; - - this.logger.debug('NetworkScanModal', `Connected devices loaded: ${this.connectedDevices.length}`); - - this.updateModalContent(); - } - - /** - * Handle a successful device connection - */ - handleDeviceConnected(data) { - this.logger.info('NetworkScanModal', `Device connected: ${data.ip || data.address}`); - - // Reload the connected devices list - this.loadConnectedDevices(); - - // Remove from the available list - const deviceIp = data.ip || data.address; - this.availableDevices = this.availableDevices.filter( - d => d.ip !== deviceIp && d.address !== deviceIp - ); - - this.updateModalContent(); - - // Show a success message - if (this.logger.success) { - this.logger.success('NetworkScanModal', this.t('network.connectionSuccess', { name: data.name || deviceIp })); - } - } - - /** - * Handle a device disconnection - */ - handleDeviceDisconnected(data) { - this.logger.info('NetworkScanModal', `Device disconnected: ${data.ip || data.address}`); - - // Reload the connected devices list - this.loadConnectedDevices(); - - this.updateModalContent(); + // Ajouter au DOM + document.body.appendChild(modalOverlay); + + // Handle clicks + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay || e.target.dataset.action === 'cancel') { + modalOverlay.remove(); + } else if (e.target.dataset.action === 'confirm') { + modalOverlay.remove(); + this.disconnectDevice(deviceIp); + } + }); + + // Focus the cancel button by default + setTimeout(() => { + const cancelBtn = modalOverlay.querySelector('.btn-cancel'); + if (cancelBtn) cancelBtn.focus(); + }, 100); + } + + /** + * Disconnect a device (without confirmation) + */ + disconnectDevice(deviceIp) { + this.logger.info('NetworkScanModal', `Disconnecting device: ${deviceIp}`); + + if (this.eventBus) { + this.eventBus.emit('network:disconnect_requested', { + ip: deviceIp, + address: deviceIp + }); } - - /** - * Handle scan errors - */ - handleScanError(data) { - this.scanning = false; - - this.logger.error('NetworkScanModal', 'Scan error:', data.error); - - this.updateModalContent(); + } + + // ======================================================================== + // HANDLERS + // ======================================================================== + + /** + * Handle scan completion + */ + handleScanComplete(data) { + this.scanning = false; + this.availableDevices = data.devices || []; + + this.logger.info( + 'NetworkScanModal', + `Scan complete: ${this.availableDevices.length} devices found` + ); + + this.updateModalContent(); + } + + /** + * Handle the connected devices list + */ + handleConnectedList(data) { + this.connectedDevices = data.devices || []; + + this.logger.debug( + 'NetworkScanModal', + `Connected devices loaded: ${this.connectedDevices.length}` + ); + + this.updateModalContent(); + } + + /** + * Handle a successful device connection + */ + handleDeviceConnected(data) { + this.logger.info('NetworkScanModal', `Device connected: ${data.ip || data.address}`); + + // Reload the connected devices list + this.loadConnectedDevices(); + + // Remove from the available list + const deviceIp = data.ip || data.address; + this.availableDevices = this.availableDevices.filter( + (d) => d.ip !== deviceIp && d.address !== deviceIp + ); + + this.updateModalContent(); + + // Show a success message + if (this.logger.success) { + this.logger.success( + 'NetworkScanModal', + this.t('network.connectionSuccess', { name: data.name || deviceIp }) + ); } - - // ======================================================================== - // UPDATE - // ======================================================================== - - /** - * Update the modal content - */ - updateModalContent() { - if (!this.container || !this.isOpen) return; - - const modalDialog = this.container.querySelector('.modal-dialog'); - if (modalDialog) { - const fullHTML = this.renderModalContent(); - // Extract the content between the first opening and last closing tag - const innerHTML = fullHTML - .replace(/^\s*