index.html
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAUNCHPAD // Test</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; background: #fff; color: #111; font: 14px/1.4 system-ui, sans-serif; display: flex; justify-content: center; }
main { width: 100%; max-width: 520px; padding: 32px 20px 48px; }
h1 { font-size: 14px; font-weight: 600; letter-spacing: .12em; margin: 0 0 4px; }
#status { color: #888; margin-bottom: 22px; min-height: 20px; }
#status.ok { color: #111; }
#pad { display: grid; grid-template-columns: repeat(9, 1fr); gap: 6px; margin-bottom: 22px; user-select: none; touch-action: none; }
.b { aspect-ratio: 1; background: #f0f0f0; border: 1px solid #e2e2e2; border-radius: 6px; cursor: pointer; padding: 0; }
.b.round { border-radius: 50%; }
.b.none { visibility: hidden; }
.b.down { outline: 2px solid #111; outline-offset: 1px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 14px; }
.label { color: #888; width: 56px; }
.sw { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #ddd; cursor: pointer; padding: 0; background: #f0f0f0; }
.sw.sel { outline: 2px solid #111; outline-offset: 2px; }
button.t { font: inherit; background: #fff; border: 1px solid #ccc; border-radius: 6px; padding: 5px 12px; cursor: pointer; }
button.t:hover { border-color: #111; }
button.t.sel { background: #111; color: #fff; border-color: #111; }
#log { margin: 18px 0 0; padding: 10px 12px; border: 1px solid #eee; border-radius: 6px; height: 170px; overflow: hidden;
font: 12px/1.5 ui-monospace, Menlo, Consolas, monospace; color: #666; white-space: pre; }
</style>
</head>
<body>
<main>
<h1>LAUNCHPAD MINI MK2</h1>
<div id="status">Suche Gerät …</div>
<div id="pad"></div>
<div class="row">
<span class="label">Farbe</span>
<span id="palette" class="row" style="margin:0"></span>
</div>
<div class="row">
<span class="label">Modus</span>
<button class="t sel" data-mode="paint">Malen (Toggle)</button>
<button class="t" data-mode="hold">Halten</button>
</div>
<div class="row">
<span class="label"></span>
<button class="t" id="btnClear">Alles aus</button>
<button class="t" id="btnAll">Alles an</button>
<button class="t" id="btnConnect">Neu verbinden</button>
</div>
<pre id="log">MIDI-Rohdaten erscheinen hier …</pre>
</main>
<script src="launchpad.js"></script>
<script>
(() => {
const $ = (s) => document.querySelector(s);
const pad = $('#pad'), statusEl = $('#status'), logEl = $('#log');
const cells = {}; // "x,y" -> Element
const virtDown = new Set();
let lp = null, color = 'red', mode = 'paint';
const lines = [];
// ---- Darstellung ----
const css = ([r, g]) => (r || g)
? `rgb(${r ? Math.min(255, 75 + r * 60) : 25},${g ? Math.min(255, 75 + g * 60) : 25},40)`
: '#f0f0f0';
// 9×9: Reihe -1 = obere Tasten, Spalte 8 = Seiten-Tasten
for (let y = -1; y < 8; y++) {
for (let x = 0; x < 9; x++) {
const b = document.createElement('button');
b.className = 'b' + ((y === -1 || x === 8) ? ' round' : '') + ((y === -1 && x === 8) ? ' none' : '');
b.addEventListener('pointerdown', (e) => { e.preventDefault(); virtDown.add(x + ',' + y); press(x, y); render(); });
const up = () => { if (virtDown.delete(x + ',' + y)) { release(x, y); render(); } };
b.addEventListener('pointerup', up);
b.addEventListener('pointerleave', up);
pad.appendChild(b);
cells[x + ',' + y] = b;
}
}
function render() {
for (const k in cells) {
const [x, y] = k.split(',').map(Number);
const c = cells[k];
c.style.background = css(lp ? lp.getLed(x, y) : [0, 0]);
c.classList.toggle('down', virtDown.has(k) || (lp && lp.isDown(x, y)));
}
}
// ---- Palette / Modus ----
const names = ['red', 'orange', 'amber', 'yellow', 'lime', 'green', 'off'];
names.forEach((n) => {
const s = document.createElement('button');
s.className = 'sw' + (n === color ? ' sel' : '');
s.style.background = css(Launchpad.COLORS[n]);
s.title = n === 'off' ? 'Radierer' : n;
s.onclick = () => {
color = n;
document.querySelectorAll('.sw').forEach((e) => e.classList.toggle('sel', e === s));
};
$('#palette').appendChild(s);
});
document.querySelectorAll('[data-mode]').forEach((b) => b.onclick = () => {
mode = b.dataset.mode;
document.querySelectorAll('[data-mode]').forEach((e) => e.classList.toggle('sel', e === b));
});
// ---- Logik: Tastendruck (Hardware oder Maus) färbt die LED ----
function press(x, y) {
if (!lp) return;
if (mode === 'hold') lp.set(x, y, color);
else {
const cur = lp.getLed(x, y), want = Launchpad.COLORS[color];
lp.set(x, y, (cur[0] === want[0] && cur[1] === want[1]) ? 'off' : color);
}
}
function release(x, y) { if (lp && mode === 'hold') lp.set(x, y, 'off'); }
$('#btnClear').onclick = () => { if (lp) lp.clear(); render(); };
$('#btnAll').onclick = () => { if (lp) lp.fill(color === 'off' ? 'yellow' : color, { edges: true }); render(); };
$('#btnConnect').onclick = init;
// ---- Monitor ----
const hex = (d) => d.map((v) => v.toString(16).padStart(2, '0')).join(' ');
function log(text) {
lines.push(text); if (lines.length > 10) lines.shift();
logEl.textContent = lines.join('\n');
}
// ---- Verbinden ----
function setStatus() {
if (lp && lp.connected) { statusEl.textContent = 'Verbunden: ' + lp.deviceName; statusEl.className = 'ok'; }
else { statusEl.textContent = 'Kein Launchpad gefunden – bitte anstecken (Maus-Pad funktioniert trotzdem).'; statusEl.className = ''; }
}
async function init() {
try { lp = await Launchpad.connect(); }
catch (e) { statusEl.textContent = e.message; statusEl.className = ''; return; }
lp.on('press', (e) => { log(`press ${e.area.padEnd(4)} x=${e.x} y=${e.y}`); press(e.x, e.y); render(); });
lp.on('release', (e) => { log(`release ${e.area.padEnd(4)} x=${e.x} y=${e.y}`); release(e.x, e.y); render(); });
lp.on('midi', (e) => log('raw ' + hex(e.data)));
lp.on('led', render);
lp.on('clear', render);
lp.on('connect', setStatus);
lp.on('disconnect', setStatus);
setStatus(); render();
}
init();
})();
</script>
</body>
</html>launchpad.js
/*
* launchpad.js — Minimal-API für Novation Launchpad Mini MK2 (Web MIDI)
*
* Koordinaten (9×9-System):
* Raster x 0–7, y 0–7 (y = 0 ist die oberste Reihe)
* obere Tasten x 0–7, y = -1 (area 'top')
* Seiten-Tasten x = 8, y 0–7 (area 'side')
*
* Farben: Name ('red', 'amber', 'off' …), [r, g] oder {r, g}, je 0–3.
*
* Nutzung:
* const lp = await Launchpad.connect();
* lp.on('press', e => lp.set(e.x, e.y, 'red'));
* lp.on('release', e => lp.set(e.x, e.y, 'off'));
*/
(function (global) {
'use strict';
const COLORS = {
off: [0, 0],
'red-dim': [1, 0], red: [3, 0],
orange: [3, 1], amber: [3, 2], yellow: [3, 3],
lime: [1, 3], green: [0, 3], 'green-dim': [0, 1]
};
const clamp = (v) => Math.max(0, Math.min(3, v | 0));
function normalize(c) {
if (typeof c === 'string') {
if (!(c in COLORS)) throw new Error('Unbekannte Farbe: ' + c);
return COLORS[c].slice();
}
if (Array.isArray(c)) return [clamp(c[0]), clamp(c[1])];
if (c && typeof c === 'object') return [clamp(c.r), clamp(c.g)];
throw new Error('Ungültige Farbe');
}
// Velocity-Kodierung (Launchpad S / Mini): 16*Grün + Rot + 12 (copy + clear flags)
const velocity = (rg) => 16 * rg[1] + rg[0] + 12;
class Launchpad {
constructor(access, opts) {
this.access = access;
this.matcher = opts.name instanceof RegExp ? opts.name
: new RegExp(opts.name || 'launchpad', 'i');
this.input = null;
this.output = null;
this.deviceName = null;
this._h = {};
this._down = new Set();
this._leds = new Map();
access.onstatechange = () => this._attach();
this._attach();
}
static async connect(opts = {}) {
if (!navigator.requestMIDIAccess) {
throw new Error('Web MIDI wird von diesem Browser nicht unterstützt (Chrome, Edge oder Firefox ≥ 108 nutzen).');
}
const access = await navigator.requestMIDIAccess({ sysex: false });
return new Launchpad(access, opts);
}
get connected() { return !!(this.input && this.output); }
// ---------- Events ----------
on(name, fn) { (this._h[name] = this._h[name] || []).push(fn); return this; }
off(name, fn) {
this._h[name] = (this._h[name] || []).filter((f) => f !== fn);
return this;
}
_emit(name, payload) { (this._h[name] || []).forEach((f) => f(payload)); }
// ---------- Ports ----------
_attach() {
const find = (map) => [...map.values()].find((p) => this.matcher.test(p.name) && p.state === 'connected');
const input = find(this.access.inputs);
const output = find(this.access.outputs);
const was = this.connected;
if (this.input && this.input !== input) this.input.onmidimessage = null;
this.input = input || null;
this.output = output || null;
if (this.input) this.input.onmidimessage = (ev) => this._handle(ev);
const now = this.connected;
if (now && !was) {
this.deviceName = this.input.name;
this._leds.clear();
this._send([0xB0, 0, 0]); // Reset: alle LEDs aus
this._emit('connect', { name: this.deviceName });
} else if (!now && was) {
this._down.clear();
this._emit('disconnect', { name: this.deviceName });
this.deviceName = null;
}
}
_send(bytes) { if (this.output) this.output.send(bytes); }
// ---------- Eingabe ----------
_handle(ev) {
const data = Array.from(ev.data);
this._emit('midi', { data });
const [s, a, b] = data;
const cmd = s & 0xF0;
let x, y, area, on;
if (cmd === 0x90 || cmd === 0x80) {
const col = a & 15, row = a >> 4;
if (col > 8 || row > 7) return;
on = cmd === 0x90 && b > 0;
if (col === 8) { x = 8; y = row; area = 'side'; }
else { x = col; y = row; area = 'grid'; }
} else if (cmd === 0xB0 && a >= 104 && a <= 111) {
x = a - 104; y = -1; area = 'top'; on = b > 0;
} else {
return;
}
const key = x + ',' + y;
if (on) this._down.add(key); else this._down.delete(key);
this._emit(on ? 'press' : 'release', {
x, y, area, index: area === 'side' ? y : area === 'top' ? x : null, velocity: b
});
}
isDown(x, y) { return this._down.has(x + ',' + y); }
// ---------- Ausgabe ----------
set(x, y, color) {
const rg = normalize(color);
const v = velocity(rg);
if (y === -1 && x >= 0 && x <= 7) this._send([0xB0, 104 + x, v]);
else if (x === 8 && y >= 0 && y <= 7) this._send([0x90, y * 16 + 8, v]);
else if (x >= 0 && x <= 7 && y >= 0 && y <= 7) this._send([0x90, y * 16 + x, v]);
else throw new Error('Ungültige Position: ' + x + ',' + y);
this._leds.set(x + ',' + y, rg);
this._emit('led', { x, y, color: rg });
return this;
}
setTop(i, color) { return this.set(i, -1, color); }
setSide(i, color) { return this.set(8, i, color); }
getLed(x, y) { return (this._leds.get(x + ',' + y) || [0, 0]).slice(); }
// Raster füllen; mit {edges:true} auch obere und seitliche Tasten
fill(color, opts = {}) {
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) this.set(x, y, color);
if (opts.edges) for (let i = 0; i < 8; i++) { this.setTop(i, color); this.setSide(i, color); }
return this;
}
// 64 Farben, zeilenweise (y*8 + x)
frame(colors) {
for (let i = 0; i < 64; i++) this.set(i % 8, (i / 8) | 0, colors[i] || 'off');
return this;
}
clear() {
this._send([0xB0, 0, 0]);
this._leds.clear();
this._emit('clear', {});
return this;
}
}
Launchpad.COLORS = COLORS;
global.Launchpad = Launchpad;
})(window);