Spaces:
Running on Zero
Running on Zero
| 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); | |