|
| 1 | + |
| 2 | +# 1) Přehrávací hlava + auto-scroll |
| 3 | + |
| 4 | +Uvidíš běžící svislou linku a editor se bude jemně posouvat. |
| 5 | + |
| 6 | +**CSS** (přidej do `<style>`): |
| 7 | + |
| 8 | +```css |
| 9 | +.playhead { |
| 10 | + position:absolute; top:0; bottom:0; width:2px; |
| 11 | + background: var(--accent, #3b82f6); opacity:.85; pointer-events:none; |
| 12 | +} |
| 13 | +``` |
| 14 | + |
| 15 | +**HTML** (do `#gridArea` přidej hned po vytvoření kontejneru – stačí jednorázově): |
| 16 | + |
| 17 | +```js |
| 18 | +const playhead = document.createElement('div'); |
| 19 | +playhead.id = 'playhead'; |
| 20 | +playhead.className = 'playhead'; |
| 21 | +gridElArea.appendChild(playhead); |
| 22 | +``` |
| 23 | + |
| 24 | +**JS** (nahoru k proměnným): |
| 25 | + |
| 26 | +```js |
| 27 | +let playheadRAF = null; |
| 28 | +``` |
| 29 | + |
| 30 | +**JS** (po `Tone.Transport.start()` ve `rebuildAndPlay()`): |
| 31 | + |
| 32 | +```js |
| 33 | +const startTime = Tone.now(); |
| 34 | +cancelAnimationFrame(playheadRAF); |
| 35 | +const bpmNow = Tone.Transport.bpm.value; |
| 36 | +const secPerQ = 60 / bpmNow; |
| 37 | + |
| 38 | +const animate = () => { |
| 39 | + const t = Tone.now() - startTime; // sekundy od startu |
| 40 | + const q = t / secPerQ; // čtvrti |
| 41 | + const x = Math.round(q * PX_PER_Q); |
| 42 | + playhead.style.left = x + 'px'; |
| 43 | + // auto-scroll, drž playhead s malou rezervou |
| 44 | + const viewL = roll.scrollLeft, viewR = viewL + roll.clientWidth - 120; |
| 45 | + if (x > viewR) roll.scrollLeft = x - (roll.clientWidth - 120); |
| 46 | + playheadRAF = requestAnimationFrame(animate); |
| 47 | +}; |
| 48 | +playheadRAF = requestAnimationFrame(animate); |
| 49 | + |
| 50 | +// při stopnutí: |
| 51 | +Tone.Transport.scheduleOnce(()=>{ |
| 52 | + cancelAnimationFrame(playheadRAF); |
| 53 | + playhead.style.left = '0px'; |
| 54 | + status('Stop (konec skladby)'); |
| 55 | +}, endSec); |
| 56 | +``` |
| 57 | + |
| 58 | +A když klikneš na **Stop**: |
| 59 | + |
| 60 | +```js |
| 61 | +btnStop.addEventListener('click', ()=>{ |
| 62 | + Tone.Transport.stop(); |
| 63 | + cancelAnimationFrame(playheadRAF); |
| 64 | + playhead.style.left = '0px'; |
| 65 | + status('Stop'); |
| 66 | +}); |
| 67 | +``` |
| 68 | + |
| 69 | +--- |
| 70 | + |
| 71 | +# 2) Respektuj tempo z MIDI (tempo map) |
| 72 | + |
| 73 | +Když načteš soubor, převezmi první nalezené tempo (fallback na ruční vstup). |
| 74 | + |
| 75 | +**Po `midi = new Midi(...)` v `loadMidiFromArrayBuffer`:** |
| 76 | + |
| 77 | +```js |
| 78 | +const tempoEv = midi.header.tempos?.[0]; |
| 79 | +if (tempoEv && tempoEv.bpm) { |
| 80 | + tempoEl.value = Math.round(tempoEv.bpm); |
| 81 | +} |
| 82 | +``` |
| 83 | +
|
| 84 | +> Pozn.: @tonejs/midi umí i vícetempové skladby; pro MVP ber první tempo. Později lze přehrávání plánovat v „ticks“ s mapou. |
| 85 | +
|
| 86 | +--- |
| 87 | +
|
| 88 | +# 3) Export zpět do .mid |
| 89 | +
|
| 90 | +Umožní stáhnout, co jsi v editoru poskládal. |
| 91 | +
|
| 92 | +**Tlačítko do toolbaru:** |
| 93 | +
|
| 94 | +```html |
| 95 | +<button id="btnExport" class="btn">⬇ Export .mid</button> |
| 96 | +``` |
| 97 | +
|
| 98 | +**JS – handler:** |
| 99 | +
|
| 100 | +```js |
| 101 | +import { Midi } from 'https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js'; |
| 102 | + |
| 103 | +const btnExport = document.getElementById('btnExport'); |
| 104 | +btnExport.addEventListener('click', ()=>{ |
| 105 | + const m = new Midi(); |
| 106 | + m.header.timeSignatures.push({ ticks:0, timeSignature:[4,4], measures:0 }); |
| 107 | + m.header.setTempo(Number(tempoEl.value) || 120); |
| 108 | + |
| 109 | + const tr = m.addTrack(); |
| 110 | + // převod čtvrťů -> sekundy |
| 111 | + const bpm = Number(tempoEl.value) || 120; |
| 112 | + const secPerQ = 60 / bpm; |
| 113 | + |
| 114 | + for (const n of notes) { |
| 115 | + tr.addNote({ |
| 116 | + midi: n.pitch, |
| 117 | + time: n.timeQ * secPerQ, |
| 118 | + duration: n.durQ * secPerQ, |
| 119 | + velocity: Math.max(0, Math.min(1, n.vel ?? 0.8)), |
| 120 | + }); |
| 121 | + } |
| 122 | + |
| 123 | + const blob = new Blob([m.toArray()], { type: 'audio/midi' }); |
| 124 | + const a = document.createElement('a'); |
| 125 | + a.href = URL.createObjectURL(blob); |
| 126 | + a.download = 'midi_editor_export.mid'; |
| 127 | + document.body.appendChild(a); |
| 128 | + a.click(); |
| 129 | + a.remove(); |
| 130 | +}); |
| 131 | +``` |
| 132 | +
|
| 133 | +--- |
| 134 | +
|
| 135 | +# 4) Rychlá kvantizace vybraných not |
| 136 | +
|
| 137 | +Jedno tlačítko, které srovná vybranou notu na mřížku. |
| 138 | +
|
| 139 | +**Tlačítko:** |
| 140 | +
|
| 141 | +```html |
| 142 | +<button id="btnQuant" class="btn">⌁ Kvantizovat</button> |
| 143 | +``` |
| 144 | +
|
| 145 | +**JS – handler:** |
| 146 | +
|
| 147 | +```js |
| 148 | +const btnQuant = document.getElementById('btnQuant'); |
| 149 | +btnQuant.addEventListener('click', ()=>{ |
| 150 | + if (!selectedId) return; |
| 151 | + const n = notes.find(x=>x.id===selectedId); |
| 152 | + if (!n) return; |
| 153 | + const step = 1/Math.max(1, Number(gridEl.value)||4); |
| 154 | + n.timeQ = Math.max(0, Math.round(n.timeQ / step) * step); |
| 155 | + n.durQ = Math.max(1/16, Math.round(n.durQ / step) * step); |
| 156 | + redrawGrid(); buildRuler(); updateSelectedPanel(); |
| 157 | +}); |
| 158 | +``` |
| 159 | +
|
| 160 | +--- |
| 161 | +
|
| 162 | +# 5) Jemné doladění UX editoru |
| 163 | +
|
| 164 | +* **Zabránit nechtěnému označování textu** během drag: |
| 165 | +
|
| 166 | +```css |
| 167 | +.grid, .note { user-select:none; -webkit-user-select:none; } |
| 168 | +``` |
| 169 | +
|
| 170 | +* **Arrow klávesy** pro jemný posun vybrané noty: |
| 171 | +
|
| 172 | +```js |
| 173 | +document.addEventListener('keydown', (e)=>{ |
| 174 | + if (!selectedId) return; |
| 175 | + const n = notes.find(x=>x.id===selectedId); |
| 176 | + if (!n) return; |
| 177 | + const stepQ = 1/Math.max(1, Number(gridEl.value)||4); |
| 178 | + if (e.key==='ArrowLeft'){ n.timeQ = Math.max(0, n.timeQ - stepQ); } |
| 179 | + else if (e.key==='ArrowRight'){ n.timeQ = n.timeQ + stepQ; } |
| 180 | + else if (e.key==='ArrowUp'){ n.pitch = Math.min(MAX_NOTE, n.pitch + 1); } |
| 181 | + else if (e.key==='ArrowDown'){ n.pitch = Math.max(MIN_NOTE, n.pitch - 1); } |
| 182 | + else return; |
| 183 | + e.preventDefault(); |
| 184 | + redrawGrid(); selectNote(n.id); |
| 185 | +}); |
| 186 | +``` |
| 187 | +
|
| 188 | +* **Kolečko myši nad notou mění velocity** (rychlejší výuka dynamiky): |
| 189 | +
|
| 190 | +```js |
| 191 | +gridElArea.addEventListener('wheel', (e)=>{ |
| 192 | + const el = e.target.closest('.note'); |
| 193 | + if (!el) return; |
| 194 | + e.preventDefault(); |
| 195 | + const n = notes.find(x => x.id === el.dataset.id); |
| 196 | + if (!n) return; |
| 197 | + const delta = (e.deltaY < 0 ? 0.05 : -0.05); |
| 198 | + n.vel = Math.max(0.05, Math.min(1, (n.vel ?? 0.8) + delta)); |
| 199 | + selectNote(n.id); |
| 200 | +}); |
| 201 | +``` |
| 202 | +
|
| 203 | +--- |
| 204 | +
|
| 205 | +# 6) Robustnější načítání demo souborů |
| 206 | +
|
| 207 | +Na GitHub Pages občas selže caching/manifest; pro jistotu ještě: |
| 208 | +
|
| 209 | +* v `populateDemoSelect()` přidej `cache: 'no-store'` (už máš), |
| 210 | +* validuj CORS v konzoli (u cizích URL), |
| 211 | +* udrž placeholder v selectu (děláš dobře), |
| 212 | +* případně dej fallback na pár vestavěných Base64 dem. |
| 213 | +
|
| 214 | +--- |
| 215 | +
|
| 216 | +# 7) Drobné bezpečné defaulty |
| 217 | +
|
| 218 | +* Když nejsou žádné noty: `estimateTotalQ()` už řeší minimum 32 — super. |
| 219 | +* Při přehrávání: disable/enable tlačítka, aby se nespouštělo víckrát: |
| 220 | +
|
| 221 | +```js |
| 222 | +btnPlay.disabled = true; |
| 223 | +Tone.Transport.scheduleOnce(()=>{ btnPlay.disabled = false; }, endSec); |
| 224 | +``` |
| 225 | +
|
0 commit comments