Skip to content

Instantly share code, notes, and snippets.

@monperrus
Created August 22, 2026 20:50
Show Gist options
  • Select an option

  • Save monperrus/47e428a00451d2833221fe7efad86e41 to your computer and use it in GitHub Desktop.

Select an option

Save monperrus/47e428a00451d2833221fe7efad86e41 to your computer and use it in GitHub Desktop.
Martin's Super Webapp tab: Piano
{
"1": {
"tab": {
"id": "piano-tab",
"label": "Piano",
"html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n<title>Piano Tab - Jazz Manouche</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/vexflow/4.2.5/vexflow-min.js\"></script>\n<style>\n html, body { margin: 0; height: 100%; overflow: hidden; background: linear-gradient(135deg, #0b0f1a 0%, #0d1117 100%); color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }\n * { box-sizing: border-box; }\n .app { display: flex; flex-direction: column; height: 100dvh; overflow: hidden; }\n .header { padding: 10px 16px 4px; text-align: center; }\n .header h1 { margin: 0; font-size: 16px; background: linear-gradient(135deg, #58a6ff, #79c0ff); -webkit-background-clip: text; -webkit-text-fill-color: transparent; font-weight: 700; }\n .header .sub { font-size: 11px; color: #8b949e; margin-top: 3px; letter-spacing: 0.3px; }\n .notation-area { flex: 1; min-height: 0; position: relative; background: rgba(22,27,34,0.6); margin: 6px 8px; border-radius: 12px; border: 1px solid #30363d; overflow: hidden; }\n #notation { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }\n #notation svg { max-width: 100%; max-height: 100%; }\n .controls { display: flex; gap: 8px; padding: 6px 12px; justify-content: center; align-items: center; flex-wrap: wrap; }\n .controls button { min-height: 46px; min-width: 46px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; border-radius: 10px; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 0 18px; transition: all 0.15s; font-weight: 500; }\n .controls button:active { transform: scale(0.95); background: #30363d; }\n .controls button.primary { background: linear-gradient(135deg, #1f6feb, #58a6ff); border-color: transparent; color: #fff; box-shadow: 0 2px 8px rgba(31,111,235,0.3); }\n .controls .tempo { font-size: 12px; color: #8b949e; display: flex; align-items: center; gap: 6px; }\n .controls .tempo input { width: 60px; background: rgba(13,17,23,0.8); border: 1px solid #30363d; color: #e6edf3; border-radius: 6px; padding: 6px; font-size: 14px; text-align: center; height: 44px; }\n .piano-container { padding: 4px 8px 8px; overflow-x: auto; -webkit-overflow-scrolling: touch; }\n .piano { display: flex; position: relative; height: 120px; min-width: 700px; user-select: none; }\n .piano .key { cursor: pointer; position: relative; display: flex; align-items: flex-end; justify-content: center; padding-bottom: 6px; font-size: 10px; color: #8b949e; transition: background 0.1s, border-color 0.1s; }\n .piano .key.white { width: 44px; height: 120px; background: linear-gradient(180deg, #f8f9fa, #e9ecef); border: 1px solid #dee2e6; border-radius: 0 0 6px 6px; color: #495057; z-index: 1; flex-shrink: 0; }\n .piano .key.white:active, .piano .key.white.active { background: linear-gradient(180deg, #58a6ff, #1f6feb); color: #fff; border-color: #1f6feb; }\n .piano .key.black { width: 28px; height: 72px; background: linear-gradient(180deg, #1a1a2e, #16213e); border: 1px solid #30363d; border-radius: 0 0 5px 5px; color: #8b949e; z-index: 2; margin-left: -14px; margin-right: -14px; flex-shrink: 0; }\n .piano .key.black:active, .piano .key.black.active { background: linear-gradient(180deg, #58a6ff, #1f6feb); color: #fff; }\n .piano .key.highlight { background: linear-gradient(180deg, #58a6ff, #1f6feb) !important; color: #fff !important; border-color: #1f6feb !important; }\n .note-indicator { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); padding: 6px 20px; border-radius: 20px; font-size: 16px; color: #fff; background: linear-gradient(135deg, #1f6feb, #58a6ff); font-weight: 700; pointer-events: none; opacity: 0; transition: opacity 0.2s; z-index: 10; box-shadow: 0 4px 12px rgba(31,111,235,0.3); }\n .note-indicator.show { opacity: 1; }\n .note-list { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 12px 8px; justify-content: center; }\n .note-list .note-chip { padding: 5px 12px; border-radius: 14px; font-size: 13px; background: rgba(22,27,34,0.6); border: 1px solid #30363d; color: #8b949e; font-weight: 500; transition: all 0.2s; }\n .note-list .note-chip.active { background: linear-gradient(135deg, #1f6feb, #58a6ff); color: #fff; border-color: transparent; box-shadow: 0 2px 8px rgba(31,111,235,0.3); }\n .note-list .note-chip.bar { border: none; background: none; color: #58a6ff; font-weight: 700; padding: 5px 2px; }\n @media (max-width: 480px) { .piano { height: 100px; min-width: 600px; } .piano .key.white { width: 38px; height: 100px; } .piano .key.black { width: 24px; height: 60px; margin-left: -12px; margin-right: -12px; } }\n</style>\n</head>\n<body>\n<div class=\"app\">\n <div class=\"header\">\n <h1>🎸 Jazz Manouche β€’ Piano</h1>\n <div class=\"sub\">La – Do – Mi – Fa | Mi – RΓ© – Do – Si | La – Do – Mi – RΓ© | Do – Si – La β€”</div>\n </div>\n\n <div class=\"notation-area\">\n <div id=\"notation\"><div style=\"color:#8b949e;font-size:14px;text-align:center;padding:20px;\">🎼 Chargement...</div></div>\n <div class=\"note-indicator\" id=\"noteIndicator\"></div>\n </div>\n\n <div class=\"controls\">\n <button id=\"playBtn\" class=\"primary\">β–Ά Play</button>\n <button id=\"resetBtn\">⟲ Reset</button>\n <div class=\"tempo\"><span>β™©=</span><input type=\"number\" id=\"bpmInput\" value=\"180\" min=\"60\" max=\"300\"></div>\n </div>\n\n <div class=\"note-list\" id=\"noteList\"></div>\n\n <div class=\"piano-container\">\n <div class=\"piano\" id=\"piano\"></div>\n </div>\n</div>\n\n<script>\nconst NOTE_NAMES = ['Do', 'Do#', 'RΓ©', 'RΓ©#', 'Mi', 'Fa', 'Fa#', 'Sol', 'Sol#', 'La', 'La#', 'Si'];\nconst melody = [\n { note: 'La', octave: 4, dur: 0.5, solfege: 'La' },\n { note: 'Do', octave: 5, dur: 0.5, solfege: 'Do' },\n { note: 'Mi', octave: 5, dur: 0.5, solfege: 'Mi' },\n { note: 'Fa', octave: 5, dur: 0.5, solfege: 'Fa' },\n { note: 'Mi', octave: 5, dur: 0.5, solfege: 'Mi' },\n { note: 'RΓ©', octave: 5, dur: 0.5, solfege: 'RΓ©' },\n { note: 'Do', octave: 5, dur: 0.5, solfege: 'Do' },\n { note: 'Si', octave: 4, dur: 0.5, solfege: 'Si' },\n { note: 'La', octave: 4, dur: 0.5, solfege: 'La' },\n { note: 'Do', octave: 5, dur: 0.5, solfege: 'Do' },\n { note: 'Mi', octave: 5, dur: 0.5, solfege: 'Mi' },\n { note: 'RΓ©', octave: 5, dur: 0.5, solfege: 'RΓ©' },\n { note: 'Do', octave: 5, dur: 0.5, solfege: 'Do' },\n { note: 'Si', octave: 4, dur: 0.5, solfege: 'Si' },\n { note: 'La', octave: 4, dur: 2.0, solfege: 'La' }\n];\nconst SOLFEGE_TO_MIDI = { 'Do': 0, 'Do#': 1, 'RΓ©': 2, 'RΓ©#': 3, 'Mi': 4, 'Fa': 5, 'Fa#': 6, 'Sol': 7, 'Sol#': 8, 'La': 9, 'La#': 10, 'Si': 11 };\nfunction noteToMidi(name, octave) { return (octave + 1) * 12 + SOLFEGE_TO_MIDI[name]; }\nfunction midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }\nfunction noteToFreq(name, octave) { return midiToFreq(noteToMidi(name, octave)); }\n\nfunction buildPiano() {\n const container = document.getElementById('piano');\n const startNote = 48; const endNote = 84;\n const whiteKeys = []; const blackKeys = [];\n for (let midi = startNote; midi <= endNote; midi++) {\n const noteInOctave = midi % 12;\n const isBlack = [1, 3, 6, 8, 10].includes(noteInOctave);\n const octave = Math.floor(midi / 12) - 1;\n const name = NOTE_NAMES[noteInOctave];\n const label = isBlack ? '' : name + octave;\n const key = document.createElement('div');\n key.className = `key ${isBlack ? 'black' : 'white'}`;\n key.dataset.midi = midi; key.dataset.name = name; key.dataset.octave = octave;\n if (!isBlack) key.textContent = label;\n key.addEventListener('mousedown', (e) => { e.preventDefault(); playSingleNote(midi); });\n key.addEventListener('touchstart', (e) => { e.preventDefault(); playSingleNote(midi); });\n if (isBlack) blackKeys.push(key); else whiteKeys.push(key);\n }\n whiteKeys.forEach(k => container.appendChild(k));\n blackKeys.forEach(k => container.appendChild(k));\n}\n\nfunction buildNoteList() {\n const container = document.getElementById('noteList');\n melody.forEach((m, i) => {\n if (i === 4 || i === 8 || i === 12) { const bar = document.createElement('span'); bar.className = 'note-chip bar'; bar.textContent = '|'; container.appendChild(bar); }\n const chip = document.createElement('span'); chip.className = 'note-chip'; chip.textContent = m.solfege; chip.dataset.index = i; container.appendChild(chip);\n });\n}\n\nfunction renderNotation() {\n const div = document.getElementById('notation');\n if (typeof VexFlow === 'undefined') { div.innerHTML = '<div style=\"color:#8b949e;font-size:14px;text-align:center;padding:20px;\">🎼 Notation musicale<br><span style=\"font-size:12px;margin-top:8px;display:block;color:#58a6ff;\">La – Do – Mi – Fa | Mi – RΓ© – Do – Si | La – Do – Mi – RΓ© | Do – Si – La β€”</span></div>'; return; }\n try {\n const VF = VexFlow; div.innerHTML = '';\n const rect = div.getBoundingClientRect();\n const w = Math.max(rect.width, 300); const h = Math.max(rect.height, 140);\n const renderer = new VF.Renderer(div, VF.Renderer.Backends.SVG);\n renderer.resize(w, h); const context = renderer.getContext(); context.setFont('Arial', 10, '');\n const stave = new VF.Stave(10, 15, w - 20); stave.addClef('treble'); stave.addTimeSignature('4/4'); stave.setContext(context).draw();\n const SOLFEGE_TO_VEX = { 'Do': 'C', 'RΓ©': 'D', 'Mi': 'E', 'Fa': 'F', 'Sol': 'G', 'La': 'A', 'Si': 'B' };\n const vfNotes = melody.map((n, i) => { const vexName = SOLFEGE_TO_VEX[n.solfege]; const octave = n.solfege === 'La' || n.solfege === 'Si' ? 4 : 5; const isLast = (i === melody.length - 1); const dur = isLast ? 'h' : '8'; return new VF.StaveNote({ clef: 'treble', keys: [vexName + '/' + octave], duration: dur }); });\n const beams = [];\n for (let g = 0; g < 4; g++) { const group = vfNotes.slice(g * 4, g === 3 ? 14 : g * 4 + 4); if (group.length > 1) { try { beams.push(new VF.Beam(group)); } catch(e) {} } }\n try { const voice = new VF.Voice({ num_beats: 4, beat_value: 4 }); voice.addTickables(vfNotes); new VF.Formatter().joinVoices([voice]).format([voice], w - 60); voice.draw(context, stave); beams.forEach(b => { try { b.setContext(context).draw(); } catch(e) {} }); } catch(e) { div.innerHTML = '<div style=\"color:#8b949e;font-size:14px;text-align:center;padding:20px;\">🎼 La – Do – Mi – Fa | Mi – RΓ© – Do – Si | La – Do – Mi – RΓ© | Do – Si – La β€”</div>'; }\n } catch(e) { div.innerHTML = '<div style=\"color:#8b949e;font-size:14px;text-align:center;padding:20px;\">🎼 La – Do – Mi – Fa | Mi – RΓ© – Do – Si | La – Do – Mi – RΓ© | Do – Si – La β€”</div>'; }\n}\n\nlet audioCtx = null; let isPlaying = false; let currentNoteIndex = -1; let timeoutIds = []; let bpm = 180;\nfunction getAudioContext() { if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); return audioCtx; }\nfunction playSingleNote(midi) {\n const ctx = getAudioContext(); const freq = midiToFreq(midi); const now = ctx.currentTime;\n const osc = ctx.createOscillator(); const gain = ctx.createGain(); const filter = ctx.createBiquadFilter();\n osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, now);\n filter.type = 'lowpass'; filter.frequency.setValueAtTime(2000, now); filter.frequency.exponentialRampToValueAtTime(800, now + 0.5);\n gain.gain.setValueAtTime(0.15, now); gain.gain.exponentialRampToValueAtTime(0.001, now + 0.8);\n osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination); osc.start(now); osc.stop(now + 0.8);\n const keys = document.querySelectorAll('.piano .key');\n keys.forEach(k => { k.classList.remove('highlight'); if (parseInt(k.dataset.midi) === midi) k.classList.add('highlight'); });\n setTimeout(() => { keys.forEach(k => k.classList.remove('highlight')); }, 400);\n}\n\nfunction playMelodyNote(noteObj, startTime, noteIdx) {\n const ctx = getAudioContext(); const freq = noteToFreq(noteObj.note, noteObj.octave); const beatDur = 60 / bpm; const dur = noteObj.dur * 2 * beatDur;\n const osc = ctx.createOscillator(); const gain = ctx.createGain(); const filter = ctx.createBiquadFilter();\n osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, startTime);\n const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.type = 'sine'; osc2.frequency.setValueAtTime(freq * 2, startTime); gain2.gain.setValueAtTime(0.03, startTime); gain2.gain.exponentialRampToValueAtTime(0.001, startTime + dur * 0.7);\n filter.type = 'lowpass'; filter.frequency.setValueAtTime(2500, startTime); filter.frequency.exponentialRampToValueAtTime(600, startTime + dur);\n gain.gain.setValueAtTime(0, startTime); gain.gain.linearRampToValueAtTime(0.18, startTime + 0.01); gain.gain.setValueAtTime(0.16, startTime + dur * 0.7); gain.gain.exponentialRampToValueAtTime(0.001, startTime + dur);\n osc.connect(filter); filter.connect(gain); gain.connect(ctx.destination); osc2.connect(gain2); gain2.connect(ctx.destination);\n osc.start(startTime); osc.stop(startTime + dur + 0.05); osc2.start(startTime); osc2.stop(startTime + dur + 0.05);\n const midi = noteToMidi(noteObj.note, noteObj.octave); const keys = document.querySelectorAll('.piano .key'); keys.forEach(k => k.classList.remove('active'));\n const keyEl = Array.from(keys).find(k => parseInt(k.dataset.midi) === midi); if (keyEl) keyEl.classList.add('active');\n const chips = document.querySelectorAll('.note-chip:not(.bar)'); chips.forEach(c => c.classList.remove('active')); if (noteIdx !== undefined && chips[noteIdx]) chips[noteIdx].classList.add('active');\n const indicator = document.getElementById('noteIndicator'); indicator.textContent = 'β™ͺ ' + noteObj.solfege; indicator.classList.add('show');\n const cleanupId = setTimeout(() => { if (keyEl) keyEl.classList.remove('active'); }, dur * 1000); timeoutIds.push(cleanupId);\n}\n\nfunction playMelody() {\n if (isPlaying) return; isPlaying = true; currentNoteIndex = 0;\n const btn = document.getElementById('playBtn'); btn.textContent = '⏸ Stop'; btn.classList.remove('primary');\n const ctx = getAudioContext(); const beatDur = 60 / bpm; let startTime = ctx.currentTime + 0.15;\n melody.forEach((noteObj, i) => {\n const dur = noteObj.dur * 2 * beatDur; const t = startTime;\n const id = setTimeout(() => { playMelodyNote(noteObj, ctx.currentTime, i); currentNoteIndex = i; if (i === melody.length - 1) setTimeout(() => stopMelody(), 600); }, (t - ctx.currentTime) * 1000);\n timeoutIds.push(id); startTime += dur;\n });\n}\n\nfunction stopMelody() {\n isPlaying = false; currentNoteIndex = -1; timeoutIds.forEach(id => clearTimeout(id)); timeoutIds = [];\n document.querySelectorAll('.piano .key').forEach(k => k.classList.remove('active')); document.querySelectorAll('.note-chip:not(.bar)').forEach(c => c.classList.remove('active')); document.getElementById('noteIndicator').classList.remove('show');\n const btn = document.getElementById('playBtn'); btn.textContent = 'β–Ά Play'; btn.classList.add('primary');\n}\n\ndocument.getElementById('playBtn').addEventListener('click', () => { if (isPlaying) stopMelody(); else playMelody(); });\ndocument.getElementById('resetBtn').addEventListener('click', () => { stopMelody(); document.querySelectorAll('.piano .key').forEach(k => k.classList.remove('highlight')); });\ndocument.getElementById('bpmInput').addEventListener('change', (e) => { bpm = parseInt(e.target.value) || 180; if (bpm < 60) bpm = 60; if (bpm > 300) bpm = 300; });\n\nbuildPiano(); buildNoteList();\nsetTimeout(renderNotation, 100);\nlet resizeTimeout; window.addEventListener('resize', () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(renderNotation, 300); });\n</script>\n</body>\n</html>"
},
"currentHash": "f0cd48f363ab5fe6ea002b34f1e115ff6172bf850f519b481eaf59c40b789001",
"previousHash": null,
"prompt": "Published shared tab"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment