const $ = (selector) => element.querySelector(selector); const ui = { drop: $('.drop-zone'), input: $('.file-input'), browse: $('.browse-button'), message: $('.message-panel'), workspace: $('.workspace'), title: $('.song-title'), fileName: $('.file-name'), badges: $('.meta-badges'), play: $('.play-button'), stop: $('.stop-button'), seek: $('.seek'), current: $('.current-time'), duration: $('.duration'), speed: $('.speed'), volume: $('.volume'), zoom: $('.zoom'), canvas: $('.piano-roll'), pitchRange: $('.pitch-range'), trackSummary: $('.track-summary'), tracks: $('.track-list'), resetMix: $('.reset-mix'), statusText: $('.status-pill span') }; const state = { data: null, audio: null, master: null, compressor: null, trackGains: [], playing: false, songTime: 0, originSong: 0, originAudio: 0, speed: 1, nextNote: 0, notes: [], activeNodes: new Set(), muted: new Set(), solo: new Set(), animation: 0, uploadToken: 0, viewStart: 0, viewEnd: 1 }; const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const formatTime = (seconds) => { const safe = Math.max(0, Number(seconds) || 0); const minutes = Math.floor(safe / 60); return `${minutes}:${(safe % 60).toFixed(1).padStart(4, '0')}`; }; const noteName = (pitch) => `${['C','C♯','D','E♭','E','F','F♯','G','A♭','A','B♭','B'][pitch % 12]}${Math.floor(pitch / 12) - 1}`; function showMessage(text, kind = '') { ui.message.textContent = text; ui.message.className = `message-panel visible ${kind}`; } function hideMessage() { ui.message.textContent = ''; ui.message.className = 'message-panel'; } async function sendFile(file) { const extension = file.name.split('.').pop().toLowerCase(); if (!['mid', 'midi'].includes(extension)) { showMessage('Format non reconnu. Choisissez un fichier .mid ou .midi.', 'error'); return; } if (file.size > 5 * 1024 * 1024) { showMessage('Ce fichier dépasse la limite de 5 Mo.', 'error'); return; } const token = ++state.uploadToken; showMessage(`Analyse de « ${file.name} »…`, 'loading'); ui.statusText.textContent = 'Analyse'; stopPlayback(true); ui.workspace.hidden = true; try { const uploaded = await upload(file); if (token !== state.uploadToken) return; props.value = { status: 'uploaded', path: uploaded.path, name: file.name }; trigger('submit'); } catch (error) { showMessage(`L’upload a échoué : ${error.message || error}`, 'error'); ui.statusText.textContent = 'Erreur'; } } ui.drop.addEventListener('click', (event) => { if (event.target !== ui.input) ui.input.click(); }); ui.drop.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); ui.input.click(); } }); ui.input.addEventListener('change', () => { if (ui.input.files[0]) sendFile(ui.input.files[0]); ui.input.value = ''; }); ['dragenter', 'dragover'].forEach((name) => ui.drop.addEventListener(name, (event) => { event.preventDefault(); ui.drop.classList.add('dragging'); })); ['dragleave', 'drop'].forEach((name) => ui.drop.addEventListener(name, (event) => { event.preventDefault(); ui.drop.classList.remove('dragging'); })); ui.drop.addEventListener('drop', (event) => { const file = event.dataTransfer.files[0]; if (file) sendFile(file); }); function ensureAudio() { if (state.audio) return; state.audio = new (window.AudioContext || window.webkitAudioContext)(); state.master = state.audio.createGain(); state.compressor = state.audio.createDynamicsCompressor(); state.compressor.threshold.value = -14; state.compressor.knee.value = 20; state.compressor.ratio.value = 6; state.master.gain.value = Number(ui.volume.value); state.master.connect(state.compressor).connect(state.audio.destination); rebuildTrackGains(); } function rebuildTrackGains() { state.trackGains.forEach((gain) => { try { gain.disconnect(); } catch (_) {} }); state.trackGains = []; if (!state.audio || !state.data) return; state.data.tracks.forEach(() => { const gain = state.audio.createGain(); gain.connect(state.master); state.trackGains.push(gain); }); updateMix(); } function waveform(program, channel) { if (channel === 9) return 'square'; if (program >= 24 && program <= 39) return 'triangle'; if (program >= 40 && program <= 55) return 'sawtooth'; if (program >= 80) return 'square'; return 'sine'; } function scheduleNote(note, nowSong) { if (!state.audio || !state.trackGains[note.t]) return; const startAt = state.audio.currentTime + Math.max(0.005, (note.s - nowSong) / state.speed); const duration = clamp((note.e - Math.max(note.s, nowSong)) / state.speed, 0.025, 12); const oscillator = state.audio.createOscillator(); const envelope = state.audio.createGain(); oscillator.type = waveform(note.g, note.c); oscillator.frequency.value = 440 * Math.pow(2, (note.p - 69) / 12); if (note.c === 9) oscillator.frequency.value = 75 + (note.p % 18) * 12; const level = (note.v / 127) * (note.c === 9 ? 0.11 : 0.075); envelope.gain.setValueAtTime(0.0001, startAt); envelope.gain.exponentialRampToValueAtTime(Math.max(level, 0.001), startAt + 0.008); envelope.gain.setValueAtTime(Math.max(level * 0.78, 0.001), startAt + Math.min(0.06, duration * 0.3)); envelope.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); oscillator.connect(envelope).connect(state.trackGains[note.t]); oscillator.start(startAt); oscillator.stop(startAt + duration + 0.02); state.activeNodes.add(oscillator); oscillator.onended = () => state.activeNodes.delete(oscillator); } function lowerBound(time) { let low = 0, high = state.notes.length; while (low < high) { const mid = (low + high) >> 1; if (state.notes[mid].s < time) low = mid + 1; else high = mid; } return low; } function cancelNodes() { state.activeNodes.forEach((node) => { try { node.stop(); } catch (_) {} }); state.activeNodes.clear(); } function exactSongTime() { if (!state.playing || !state.audio) return state.songTime; return state.originSong + (state.audio.currentTime - state.originAudio) * state.speed; } function startPlayback() { if (!state.data || state.playing) return; ensureAudio(); state.audio.resume(); if (state.songTime >= state.data.duration - 0.01) state.songTime = 0; state.originSong = state.songTime; state.originAudio = state.audio.currentTime; state.nextNote = lowerBound(Math.max(0, state.songTime - 0.01)); state.playing = true; ui.play.textContent = '❚❚'; ui.play.setAttribute('aria-label', 'Pause'); frame(); } function stopPlayback(reset = false) { if (state.playing) state.songTime = clamp(exactSongTime(), 0, state.data ? state.data.duration : 0); state.playing = false; cancelAnimationFrame(state.animation); cancelNodes(); if (reset) state.songTime = 0; ui.play.textContent = '▶'; ui.play.setAttribute('aria-label', 'Lecture'); updateTimeline(); } function seekTo(time) { const wasPlaying = state.playing; stopPlayback(false); state.songTime = clamp(time, 0, state.data.duration); updateTimeline(); if (wasPlaying) startPlayback(); } function frame() { if (!state.playing) return; const nowSong = exactSongTime(); if (nowSong >= state.data.duration) { state.songTime = state.data.duration; stopPlayback(false); return; } const horizon = nowSong + 1.2 * state.speed; while (state.nextNote < state.notes.length && state.notes[state.nextNote].s <= horizon) { const note = state.notes[state.nextNote++]; if (note.e > nowSong) scheduleNote(note, nowSong); } state.songTime = nowSong; updateTimeline(); state.animation = requestAnimationFrame(frame); } function updateTimeline() { if (!state.data) return; const time = clamp(state.songTime, 0, state.data.duration); ui.seek.value = String(time); ui.current.textContent = formatTime(time); drawPianoRoll(); } ui.play.addEventListener('click', () => state.playing ? stopPlayback(false) : startPlayback()); ui.stop.addEventListener('click', () => stopPlayback(true)); ui.seek.addEventListener('input', () => seekTo(Number(ui.seek.value))); ui.speed.addEventListener('change', () => { const wasPlaying = state.playing; stopPlayback(false); state.speed = Number(ui.speed.value); if (wasPlaying) startPlayback(); }); ui.volume.addEventListener('input', () => { if (state.master && state.audio) state.master.gain.setTargetAtTime(Number(ui.volume.value), state.audio.currentTime, .02); }); ui.zoom.addEventListener('input', drawPianoRoll); ui.canvas.addEventListener('click', (event) => { if (!state.data) return; const rect = ui.canvas.getBoundingClientRect(); seekTo(state.viewStart + ((event.clientX - rect.left) / rect.width) * (state.viewEnd - state.viewStart)); }); function updateMix() { if (!state.data) return; const hasSolo = state.solo.size > 0; state.data.tracks.forEach((track, index) => { const audible = hasSolo ? state.solo.has(index) : !state.muted.has(index); if (state.trackGains[index] && state.audio) { state.trackGains[index].gain.setTargetAtTime(audible ? 1 : 0, state.audio.currentTime, .015); } const row = ui.tracks.querySelector(`[data-track="${index}"]`); if (row) { row.classList.toggle('silent', !audible); row.querySelector('.mute').classList.toggle('active', state.muted.has(index)); row.querySelector('.solo').classList.toggle('active', state.solo.has(index)); } }); drawPianoRoll(); } ui.tracks.addEventListener('click', (event) => { const button = event.target.closest('.mix-button'); if (!button) return; const index = Number(button.closest('.track-row').dataset.track); const targetSet = button.classList.contains('mute') ? state.muted : state.solo; targetSet.has(index) ? targetSet.delete(index) : targetSet.add(index); updateMix(); }); ui.resetMix.addEventListener('click', () => { state.muted.clear(); state.solo.clear(); updateMix(); }); function renderTracks() { ui.tracks.replaceChildren(); state.data.tracks.forEach((track) => { const row = document.createElement('div'); row.className = 'track-row'; row.dataset.track = track.index; row.style.setProperty('--track-color', track.color); const color = document.createElement('i'); color.className = 'track-color'; color.style.background = track.color; color.style.color = track.color; const identity = document.createElement('div'); identity.className = 'track-name'; const name = document.createElement('strong'); name.textContent = track.name; const instrument = document.createElement('span'); instrument.textContent = track.instrument; identity.append(name, instrument); const stats = document.createElement('span'); stats.className = 'track-stats'; stats.textContent = `${track.notes.length.toLocaleString('fr-FR')} notes · canal ${track.channels.join(', ')}`; const buttons = document.createElement('div'); buttons.className = 'track-buttons'; const mute = document.createElement('button'); mute.type = 'button'; mute.className = 'mix-button mute'; mute.textContent = 'M'; mute.title = 'Muet'; const solo = document.createElement('button'); solo.type = 'button'; solo.className = 'mix-button solo'; solo.textContent = 'S'; solo.title = 'Solo'; buttons.append(mute, solo); row.append(color, identity, stats, buttons); ui.tracks.append(row); }); } function drawPianoRoll() { if (!state.data) return; const canvas = ui.canvas; const rect = canvas.getBoundingClientRect(); if (!rect.width) return; const dpr = Math.min(window.devicePixelRatio || 1, 2); const width = Math.round(rect.width * dpr), height = Math.round(rect.height * dpr); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const w = rect.width, h = rect.height; ctx.clearRect(0, 0, w, h); const zoom = Number(ui.zoom.value); const span = state.data.duration / zoom; let start = zoom === 1 ? 0 : clamp(state.songTime - span * .35, 0, Math.max(0, state.data.duration - span)); const end = Math.min(state.data.duration, start + span); state.viewStart = start; state.viewEnd = end; const pitchSpan = Math.max(12, state.data.pitch_max - state.data.pitch_min + 3); const lowPitch = state.data.pitch_min - 1; ctx.strokeStyle = 'rgba(148,163,184,.10)'; ctx.lineWidth = 1; const gridSeconds = span > 180 ? 30 : span > 60 ? 10 : span > 20 ? 5 : 1; for (let second = Math.ceil(start / gridSeconds) * gridSeconds; second < end; second += gridSeconds) { const x = ((second - start) / (end - start)) * w; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); ctx.fillStyle = '#475569'; ctx.font = '9px ui-monospace'; ctx.fillText(formatTime(second), x + 4, 12); } for (let octave = Math.ceil(lowPitch / 12) * 12; octave <= state.data.pitch_max; octave += 12) { const y = h - ((octave - lowPitch) / pitchSpan) * h; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } const hasSolo = state.solo.size > 0; state.data.tracks.forEach((track, trackIndex) => { const audible = hasSolo ? state.solo.has(trackIndex) : !state.muted.has(trackIndex); ctx.globalAlpha = audible ? .82 : .12; ctx.fillStyle = track.color; for (const note of track.notes) { if (note.e < start || note.s > end) continue; const x = ((Math.max(note.s, start) - start) / (end - start)) * w; const right = ((Math.min(note.e, end) - start) / (end - start)) * w; const y = h - ((note.p - lowPitch + 1) / pitchSpan) * h; ctx.fillRect(x, y, Math.max(1.5, right - x), Math.max(2, h / pitchSpan * .72)); } }); ctx.globalAlpha = 1; if (state.songTime >= start && state.songTime <= end) { const x = ((state.songTime - start) / (end - start)) * w; ctx.fillStyle = '#f8fafc'; ctx.fillRect(x - .75, 0, 1.5, h); ctx.beginPath(); ctx.moveTo(x - 5, 0); ctx.lineTo(x + 5, 0); ctx.lineTo(x, 7); ctx.fill(); } } function renderData(data) { stopPlayback(true); state.data = data; state.notes = data.tracks.flatMap((track) => track.notes).sort((a, b) => a.s - b.s || a.p - b.p); state.muted.clear(); state.solo.clear(); state.speed = Number(ui.speed.value); rebuildTrackGains(); hideMessage(); ui.workspace.hidden = false; ui.title.textContent = data.title; ui.fileName.textContent = data.file_name; ui.duration.textContent = formatTime(data.duration); ui.seek.max = String(data.duration); ui.pitchRange.textContent = `${noteName(data.pitch_min)} → ${noteName(data.pitch_max)}`; ui.trackSummary.textContent = `${data.track_count} piste${data.track_count > 1 ? 's' : ''} · ${data.note_count.toLocaleString('fr-FR')} notes`; ui.badges.replaceChildren(); [`${data.bpm} BPM`, data.time_signature, `MIDI type ${data.format}`, `${formatTime(data.duration)}`].forEach((text) => { const badge = document.createElement('span'); badge.className = 'meta-badge'; badge.textContent = text; ui.badges.append(badge); }); renderTracks(); updateTimeline(); ui.statusText.textContent = 'Prêt'; } function receive(value) { if (!value || typeof value !== 'object') return; if (value.status === 'ready') renderData(value); if (value.status === 'error') { stopPlayback(true); ui.workspace.hidden = true; showMessage(value.message || 'Impossible de lire ce fichier.', 'error'); ui.statusText.textContent = 'Erreur'; } if (value.status === 'empty') { ui.workspace.hidden = true; hideMessage(); } } watch('value', () => receive(props.value)); receive(props.value); new ResizeObserver(() => drawPianoRoll()).observe(ui.canvas);