/
AgafonovTimur
/
NotesInWeb
Обзор
Документация
Войти
/
AgafonovTimur
/
NotesInWeb
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
index.html
453 строки
259 KB
AgafonovTimur
Update index.html
15 июл 2026, 11:01
Не верифицирован
15 июл 2026, 11:01
2b4f322
Код
Авторство
О чём код?
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <title>MIDI Chord Display</title> <style> @font-face { font-family: 'PT Sans Bold Embedded'; src: url(data:font/ttf;base64,AAEAAAASAQAABAAgRkZUTVewOToAAtLYAAAAHEdERUYc8xmCAAKTeAAAAJhHUE9T6iW4XQACm0QAADeUR1NVQjagQLMAApQQAAAHMk9TLzKN/nnIAAABqAAAAGBjbWFw9kLO1gAADUAAAAUWY3Z0IAo3AlIAABVcAAAALGZw … [Строка слишком длинная. Вы можете скачать файл] font-weight: 700; font-style: normal; font-display: swap; } :root { /* ====== НАСТРОЙКИ (меняйте здесь) ====== */ --bg-color: #1a1a1a; /* ЦВЕТ ФОНА — поставьте сюда цвет стана из note-trainer */ --text-color: #8e8e8e; /* ЦВЕТ ТЕКСТА — поставьте сюда цвет кнопок из note-trainer */ --font-size-base: 700px; /* БАЗОВЫЙ РАЗМЕР ШРИФТА аккорда (при масштабе 100%) */ --panel-top: max(20px, calc(50% - 1000px)); /* МЕСТОПОЛОЖЕНИЕ ПАНЕЛИ настроек — по вертикали (центр экрана минус 1000px вверх, но не выше 20px от верха) */ /* ========================================= */ --zoom: 100; /* текущий масштаб в процентах, меняется кнопками +/- */ } html, body { margin: 0; padding: 0; width: 100%; height: 100%; background: var(--bg-color); overflow: hidden; } #chordDisplay { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); color: var(--text-color); font-family: 'PT Sans Bold Embedded', Arial, Helvetica, sans-serif; font-weight: 700; font-size: calc(var(--font-size-base) * var(--zoom) / 100); white-space: nowrap; user-select: none; text-align: center; line-height: 1; } /* Панель настроек MIDI и зума — по умолчанию скрыта, показывается по клавише Y/Н (повторное нажатие скрывает) */ #controlPanel { position: fixed; top: var(--panel-top, 20px); left: 50%; transform: translateX(-50%); padding: 10px 14px; background: rgba(0,0,0,0.75); color: #eee; font-family: Arial, sans-serif; font-size: 13px; z-index: 10; border-radius: 10px; display: none; } #controlPanel select, #controlPanel button { font-size: 13px; margin: 2px 4px 2px 0; } #zoomLabel { display: inline-block; min-width: 45px; } .row { margin-bottom: 6px; } </style> </head> <body> <div id="controlPanel"> <div class="row"> <label>MIDI вход: </label> <select id="inputSelect"><option>—</option></select> </div> <div class="row"> <label>MIDI выход (напр. loopMIDI): </label> <select id="outputSelect"><option>—</option></select> </div> <div class="row" id="statusLine" style="color:#8f8;">Ожидание доступа к MIDI...</div> <div class="row"> <button id="zoomOut">-</button> <span id="zoomLabel">100%</span> <button id="zoomIn">+</button> <span style="margin-left:8px;color:#999;">(Shift + "-" = сброс до 100%)</span> </div> <div class="row"> <label><input type="checkbox" id="flatsToggle" checked> Бемоли вместо диезов (переключение — И/B)</label> </div> </div> <div id="chordDisplay"></div> <script> (function () { const NOTE_NAMES_SHARP = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; const NOTE_NAMES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']; let useFlats = true; function noteNames() { return useFlats ? NOTE_NAMES_FLAT : NOTE_NAMES_SHARP; } // Шаблоны аккордов: интервалы от корня (0), отсортированы по возрастанию. // Порядок важен только для приоритета при полном совпадении по количеству нот — // сначала проверяем более крупные (длинные) аккорды. const CHORD_TEMPLATES = [ { name: '11', intervals: [0,2,4,5,7,10] }, { name: 'maj11', intervals: [0,2,4,5,7,11] }, { name: 'm11', intervals: [0,2,3,5,7,10] }, { name: '9', intervals: [0,2,4,7,10] }, { name: 'maj9', intervals: [0,2,4,7,11] }, { name: 'm9', intervals: [0,2,3,7,10] }, { name: 'minmaj9', intervals: [0,2,3,7,11] }, { name: '6add9', intervals: [0,2,4,7,9] }, { name: '7', intervals: [0,4,7,10] }, { name: 'maj7', intervals: [0,4,7,11] }, { name: 'm7', intervals: [0,3,7,10] }, { name: '7b5', intervals: [0,4,6,10] }, { name: '7#5', intervals: [0,4,8,10] }, { name: 'm7b5', intervals: [0,3,6,10] }, { name: 'minmaj7', intervals: [0,3,7,11] }, { name: '7sus4', intervals: [0,5,7,10] }, { name: '6', intervals: [0,4,7,9] }, { name: 'min6', intervals: [0,3,7,9] }, { name: 'dim7', intervals: [0,3,6,9] }, { name: 'dimM7', intervals: [0,3,6,11] }, { name: 'augM7', intervals: [0,4,8,11] }, { name: 'add9', intervals: [0,2,4,7] }, { name: 'm add9', intervals: [0,2,3,7] }, { name: 'maj', intervals: [0,4,7] }, { name: 'min', intervals: [0,3,7] }, { name: 'sus2', intervals: [0,2,7] }, { name: 'sus4', intervals: [0,5,7] }, { name: 'dim', intervals: [0,3,6] }, { name: 'aug', intervals: [0,4,8] }, { name: '5', intervals: [0,7] }, ]; const chordDisplay = document.getElementById('chordDisplay'); const inputSelect = document.getElementById('inputSelect'); const outputSelect = document.getElementById('outputSelect'); const statusLine = document.getElementById('statusLine'); const zoomLabel = document.getElementById('zoomLabel'); let midiAccess = null; let currentInput = null; let currentOutput = null; let heldNotes = new Set(); // MIDI note numbers currently held let pendingOff = new Map(); // note -> timeoutId, delays actual removal to avoid flicker const NOTE_OFF_DELAY_MS = 60; // защита от дребезга/повторных Note On/Off // Окно захвата аккорда: при каждом новом нажатии таймер перезапускается. // Ноты отображаются только после того, как пройдёт CHORD_CAPTURE_WINDOW_MS // мс "тишины" (без новых нажатий) с момента последней нажатой ноты. // Правьте это значение, чтобы изменить длительность окна (в миллисекундах). const CHORD_CAPTURE_WINDOW_MS = 50; let capturedNotes = new Set(); // ноты, накапливаемые в текущем окне захвата let captureFinished = true; // true, если предыдущий захваченный аккорд уже отображён let captureWindowTimer = null; // Защита от MIDI-эха: если мы только что отправили сообщение на выход (например, loopMIDI), // а DAW с включённым MIDI Thru/Echo отправляет его обратно нам же на вход — // такое "эхо" не должно считаться новым нажатием/отпусканием ноты. const recentlySent = new Map(); // ключ "status,note,vel" -> timestamp последней отправки const ECHO_WINDOW_MS = 400; function markSent(data) { const key = data[0] + ',' + data[1] + ',' + data[2]; recentlySent.set(key, performance.now()); } function isEcho(data) { const key = data[0] + ',' + data[1] + ',' + data[2]; const ts = recentlySent.get(key); if (ts === undefined) return false; return (performance.now() - ts) < ECHO_WINDOW_MS; } // Защита от дублей: мы слушаем ВСЕ MIDI-входы сразу (bindAllInputs), и если одно и то же // физическое нажатие видно системе через два разных порта (напр. клавиатура + её зеркало // через loopMIDI/DAW), одно и то же сообщение приходит несколько раз подряд. // Игнорируем повтор абсолютно идентичного сообщения в течение короткого окна. const recentlyReceived = new Map(); // ключ "status,note,vel" -> timestamp последнего приёма const DUPLICATE_WINDOW_MS = 30; function isDuplicate(data) { const key = data[0] + ',' + data[1] + ',' + data[2]; const now = performance.now(); const ts = recentlyReceived.get(key); recentlyReceived.set(key, now); if (ts === undefined) return false; return (now - ts) < DUPLICATE_WINDOW_MS; } let zoom = 100; const ZOOM_MIN = 10; const ZOOM_MAX = 1000; const ZOOM_STEP = 10; function applyZoom() { document.documentElement.style.setProperty('--zoom', zoom); zoomLabel.textContent = zoom + '%'; } document.getElementById('zoomIn').addEventListener('click', () => { zoom = Math.min(ZOOM_MAX, zoom + ZOOM_STEP); applyZoom(); }); document.getElementById('zoomOut').addEventListener('click', (e) => { if (e.shiftKey) { zoom = 100; } else { zoom = Math.max(ZOOM_MIN, zoom - ZOOM_STEP); } applyZoom(); }); // Поддержка клавиш + / - на клавиатуре компьютера тоже (не обязательно, но удобно) window.addEventListener('keydown', (e) => { if (e.key === '+' || e.key === '=') { zoom = Math.min(ZOOM_MAX, zoom + ZOOM_STEP); applyZoom(); } else if (e.key === '-') { if (e.shiftKey) { zoom = 100; } else { zoom = Math.max(ZOOM_MIN, zoom - ZOOM_STEP); } applyZoom(); } else if (e.key === 'y' || e.key === 'Y' || e.key === 'н' || e.key === 'Н') { const panel = document.getElementById('controlPanel'); panel.style.display = (panel.style.display === 'none' || !panel.style.display) ? 'block' : 'none'; } else if (e.key === 'b' || e.key === 'B' || e.key === 'и' || e.key === 'И') { useFlats = !useFlats; flatsToggle.checked = useFlats; updateDisplay(); } }); const flatsToggle = document.getElementById('flatsToggle'); flatsToggle.addEventListener('change', () => { useFlats = flatsToggle.checked; updateDisplay(); }); const PLACEHOLDER_TEXT = 'нажмите ноту на клавиатуре'; let hasPlayedNote = false; function detectChord(noteNumbers) { if (noteNumbers.size === 0) return hasPlayedNote ? '' : PLACEHOLDER_TEXT; const names = noteNames(); const pitchClasses = [...new Set([...noteNumbers].map(n => n % 12))]; if (pitchClasses.length === 1) { return names[pitchClasses[0]]; } // Проверяем от самых "больших" (по числу нот) шаблонов к самым простым, // чтобы не путать частичные совпадения. const sortedTemplates = [...CHORD_TEMPLATES].sort((a, b) => b.intervals.length - a.intervals.length); for (const tmpl of sortedTemplates) { if (tmpl.intervals.length !== pitchClasses.length) continue; for (const root of pitchClasses) { const rel = pitchClasses .map(pc => (pc - root + 12) % 12) .sort((a, b) => a - b); if (arraysEqual(rel, tmpl.intervals)) { if (tmpl.name === 'maj') return names[root]; if (tmpl.name === 'min') return `${names[root]}m`; return `${names[root]} ${tmpl.name}`; } } } // Совпадение не найдено — просто перечисляем ноты return pitchClasses.map(pc => names[pc]).join(' '); } function arraysEqual(a, b) { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function updateDisplay() { chordDisplay.textContent = detectChord(heldNotes); } function onMIDIMessage(event) { const data = event.data; const status = data[0] & 0xf0; const note = data[1]; const velocity = data[2]; // Дубль: то же сообщение только что пришло с другого входа — игнорируем повтор. if (isDuplicate(data)) { return; } // Если это эхо нашего же недавно отправленного сообщения (DAW MIDI Thru // отправила его нам обратно через loopMIDI) — игнорируем, чтобы не зациклиться // и не пересчитывать аккорд на пустом месте. if (isEcho(data)) { return; } // Пасстру: пересылаем сырое сообщение на выбранный выход как есть if (currentOutput) { try { currentOutput.send(data); markSent(data); } catch (err) { console.error('Ошибка отправки на MIDI выход:', err); } } if (status === 0x90 && velocity > 0) { hasPlayedNote = true; if (pendingOff.has(note)) { clearTimeout(pendingOff.get(note)); pendingOff.delete(note); } if (captureFinished) { // Предыдущий аккорд уже был отображён (или это самое первое нажатие) — // начинаем накапливать новый аккорд с чистого листа. capturedNotes.clear(); captureFinished = false; } capturedNotes.add(note); // Любое новое нажатие перезапускает таймер: отображение произойдёт // только после CHORD_CAPTURE_WINDOW_MS мс без новых нажатий. if (captureWindowTimer) { clearTimeout(captureWindowTimer); } captureWindowTimer = setTimeout(() => { heldNotes = new Set(capturedNotes); updateDisplay(); captureFinished = true; captureWindowTimer = null; }, CHORD_CAPTURE_WINDOW_MS); } else if (status === 0x80 || (status === 0x90 && velocity === 0)) { // Отпускания клавиш больше не учитываются: нота остаётся в heldNotes // и в отображении до тех пор, пока не будет нажата новая. return; } } // Как в note-trainer: слушаем ВСЕ подключённые входы сразу (не только "выбранный"), // и переподключаемся при любом изменении состояния устройств (onstatechange). // Это устраняет проблему, когда конкретное устройство не попадало в выбор/не биндилось. function bindAllInputs() { if (!midiAccess) return; midiAccess.inputs.forEach(inp => { inp.onmidimessage = onMIDIMessage; }); } function populateSelects() { inputSelect.innerHTML = ''; outputSelect.innerHTML = ''; const inputs = [...midiAccess.inputs.values()]; const outputs = [...midiAccess.outputs.values()]; if (inputs.length === 0) { inputSelect.innerHTML = '<option>Нет входов</option>'; statusLine.textContent = 'MIDI входы не найдены'; statusLine.style.color = '#f88'; } else { inputs.forEach(inp => { const opt = document.createElement('option'); opt.value = inp.id; opt.textContent = inp.name + (inp.state !== 'connected' ? ' (не подключено)' : ''); inputSelect.appendChild(opt); }); statusLine.textContent = `Входы: ${inputs.map(i => i.name).join(', ')}`; statusLine.style.color = '#8f8'; } if (outputs.length === 0) { outputSelect.innerHTML = '<option>Нет выходов</option>'; } else { outputs.forEach(out => { const opt = document.createElement('option'); opt.value = out.id; opt.textContent = out.name; outputSelect.appendChild(opt); }); } // Слушаем ВСЕ входы (не блокируемся выбором одного устройства в списке) bindAllInputs(); // Выход выбираем один — тот, что отмечен в списке (или первый по умолчанию) if (outputs.length > 0) { const wanted = outputSelect.value || outputs[0].id; selectOutput(wanted); } } function selectOutput(id) { currentOutput = [...midiAccess.outputs.values()].find(o => o.id === id) || null; if (currentOutput) { outputSelect.value = id; } } outputSelect.addEventListener('change', () => selectOutput(outputSelect.value)); function requestAccess() { if (!navigator.requestMIDIAccess) { statusLine.textContent = 'Браузер не поддерживает Web MIDI (нужен Chrome/Edge)'; statusLine.style.color = '#f88'; return; } navigator.requestMIDIAccess({ sysex: false }) .then(access => { midiAccess = access; populateSelects(); access.onstatechange = () => populateSelects(); }) .catch(err => { statusLine.textContent = 'Ошибка доступа к MIDI: ' + err + '. Если файл открыт напрямую (file://), запустите его через локальный сервер (http://localhost) — иначе браузер блокирует MIDI.'; statusLine.style.color = '#f88'; }); } chordDisplay.textContent = PLACEHOLDER_TEXT; requestAccess(); applyZoom(); })(); </script> </body> </html>