fffiloni's picture
Upload 7 files
b7f3d13 verified
Raw
History Blame Contribute Delete
15.7 kB
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);