stemforge / index.html
Ryanrealaf's picture
Upload index.html
e352eda verified
Raw
History Blame Contribute Delete
29.3 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StemForge | Advanced Analysis + MIDI</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #e4e4e7; }
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
canvas { filter: drop-shadow(0 0 4px rgba(217, 70, 239, 0.3)); }
</style>
</head>
<body class="min-h-screen flex flex-col items-center p-4 md:p-8 selection:bg-fuchsia-900 selection:text-white">
<div class="w-full max-w-6xl flex flex-col gap-6">
<header class="flex flex-col md:flex-row items-start md:items-center justify-between border-b border-zinc-800 pb-4">
<div>
<h1 class="text-3xl font-black tracking-tighter uppercase text-white">Stem<span class="text-zinc-600">Forge</span></h1>
<p class="text-xs font-mono text-zinc-500 mt-1">ENGINE: HF GRADIO QUEUE | 6-STEM + MIDI PIPELINE</p>
</div>
<div class="mt-4 md:mt-0 px-3 py-1 bg-zinc-900 border border-zinc-800 rounded-sm">
<span class="text-[10px] font-mono text-fuchsia-500 tracking-widest uppercase">Build While Bleeding</span>
</div>
</header>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 flex flex-col gap-4">
<div class="bg-black border border-zinc-800 p-4 rounded-sm flex flex-col gap-4 relative overflow-hidden group hover:border-fuchsia-900 transition-colors duration-300">
<div id="progress-bar-container" class="absolute top-0 left-0 w-full h-1 bg-zinc-900 hidden">
<div id="progress-bar" class="h-full bg-fuchsia-600 w-0 transition-all duration-300 ease-out"></div>
</div>
<label class="flex flex-col items-center justify-center h-32 border-2 border-dashed border-zinc-700 bg-zinc-900/50 cursor-pointer hover:bg-zinc-800 transition-colors">
<input type="file" id="file-input" accept="audio/*" class="hidden" />
<svg class="w-8 h-8 text-zinc-500 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<span id="file-name-display" class="text-xs font-mono text-zinc-400 text-center px-2 truncate w-full">SELECT TARGET AUDIO</span>
</label>
<button id="process-btn" disabled class="w-full bg-white text-black font-bold uppercase tracking-widest py-3 rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:bg-fuchsia-600 hover:text-white transition-all">
Execute
</button>
<div id="status-badge" class="hidden absolute top-4 right-4 flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-fuchsia-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-fuchsia-500"></span>
</div>
</div>
</div>
<div class="lg:col-span-2 bg-black border border-zinc-800 rounded-sm flex flex-col overflow-hidden h-64">
<div class="bg-zinc-900 border-b border-zinc-800 px-3 py-1.5 flex items-center justify-between">
<span class="text-[10px] font-mono text-zinc-500 uppercase tracking-widest">>> Telemetry Stream</span>
<button id="clear-log-btn" class="text-[10px] text-zinc-600 hover:text-fuchsia-400 uppercase font-mono">Clear</button>
</div>
<div id="terminal-output" class="p-3 font-mono text-[11px] overflow-y-auto flex-1 space-y-1 scrollbar-hide text-zinc-300">
<div class="text-zinc-600">System Ready. Awaiting source injection...</div>
</div>
</div>
</div>
<div id="global-transport" class="hidden bg-black border border-zinc-800 p-3 rounded-sm flex items-center gap-4 sticky top-2 z-50">
<button id="master-play-btn" class="bg-white text-black w-10 h-10 flex items-center justify-center hover:bg-fuchsia-600 hover:text-white transition-colors rounded-sm">
<svg id="play-icon" class="w-5 h-5 ml-1" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
<svg id="pause-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
</button>
<div class="flex-1 flex items-center gap-3">
<span id="time-current" class="text-[10px] font-mono text-zinc-500 w-8">0:00</span>
<input type="range" id="master-scrubber" min="0" max="100" value="0" step="0.1" class="w-full h-1 bg-zinc-800 appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-sm cursor-pointer">
<span id="time-total" class="text-[10px] font-mono text-zinc-500 w-8 text-right">0:00</span>
</div>
</div>
<div id="stems-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pb-12 empty:hidden"></div>
</div>
<script>
// ENTRY POINT: HF_SPACE_URL
const API_BASE = "https://ryanrealaf-stemforge.hf.space";
let stems = [];
let isPlaying = false;
let masterDuration = 0;
let audioElements = [];
let animationFrameId;
const terminal = document.getElementById('terminal-output');
const fileInput = document.getElementById('file-input');
const processBtn = document.getElementById('process-btn');
const log = (msg, type = 'info') => {
const time = new Date().toTimeString().split(' ')[0];
const div = document.createElement('div');
let colorClass = 'text-zinc-300';
if(type === 'error') colorClass = 'text-red-500';
if(type === 'warning') colorClass = 'text-amber-500';
if(type === 'success') colorClass = 'text-fuchsia-400';
div.innerHTML = `<span class="text-zinc-600 mr-2">[${time}]</span><span class="${colorClass}">${msg}</span>`;
terminal.appendChild(div);
terminal.scrollTop = terminal.scrollHeight;
};
const updateProgress = (pct) => {
document.getElementById('progress-bar').style.width = `${pct}%`;
};
document.getElementById('clear-log-btn').addEventListener('click', () => terminal.innerHTML = '');
fileInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
document.getElementById('file-name-display').innerText = file.name.toUpperCase();
processBtn.disabled = false;
log(`File staged: ${file.name}`);
}
});
processBtn.addEventListener('click', async () => {
const file = fileInput.files[0];
document.getElementById('progress-bar-container').classList.remove('hidden');
document.getElementById('status-badge').classList.remove('hidden');
processBtn.disabled = true;
processBtn.innerText = "Extracting...";
try {
updateProgress(10);
log("Initiating upload sequence...");
const formData = new FormData();
formData.append("files", file);
// BWB Fix: Route through /gradio_api for v4 spaces
const apiPrefix = `${API_BASE}/gradio_api`;
const upRes = await fetch(`${apiPrefix}/upload`, { method: "POST", body: formData });
if (!upRes.ok) throw new Error(`Upload rejected (HTTP ${upRes.status}).`);
const upData = await upRes.json();
// BWB Fix: Handle strict dictionary schemas in Gradio 4
let uploadedFile = Array.isArray(upData) ? upData[0] : upData;
let path = typeof uploadedFile === 'string' ? uploadedFile : (uploadedFile.path || uploadedFile.name || uploadedFile.orig_name);
if (!path) {
log("Critical: Could not extract file path. Raw: " + JSON.stringify(uploadedFile), 'error');
throw new Error("Invalid upload response schema");
}
updateProgress(30);
log("Upload complete. Temp Path: " + path);
log("Requesting compute node allocation (Queue)...");
const sessionHash = Math.random().toString(36).substring(2);
// BWB Fix: Pass the exact upload dictionary back to the predictor
const filePayload = typeof uploadedFile === 'object' ? uploadedFile : { path: path, orig_name: file.name };
const payload = { data: [filePayload], fn_index: 0, session_hash: sessionHash };
const joinRes = await fetch(`${apiPrefix}/queue/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!joinRes.ok) {
log("Queue join failed. Falling back to blocking request...", "warning");
const res = await fetch(`${apiPrefix}/run/predict`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
const data = await res.json();
if (data.detail === "Not Found") throw new Error("API endpoint not found. Verify fn_index in openapi.json");
processOutputs(data);
return;
}
log("Node allocated. Establishing EventSource stream...");
updateProgress(40);
const stream = new EventSource(`${apiPrefix}/queue/data?session_hash=${sessionHash}`);
stream.onmessage = (e) => {
if (e.data === "close") { stream.close(); return; }
const msg = JSON.parse(e.data);
switch(msg.msg) {
case 'estimation':
log(`Queue Status: Rank ${msg.rank || 0}. ETA: ${msg.rank_eta ? Math.round(msg.rank_eta) + 's' : 'calculating...'}`, "warning");
updateProgress(45);
break;
case 'process_starts':
log("Process executing: Neural separation initialized.", "success");
updateProgress(60);
break;
case 'process_generating':
log("Process generating: Demultiplexing frequencies...", "success");
updateProgress(80);
break;
case 'process_completed':
stream.close();
if (msg.success) {
log("Compute finished. Extracting payload...", "success");
updateProgress(95);
processOutputs(msg.output.data);
} else {
log("API Compute Error: " + (msg.error || "Unknown failure"), "error");
resetUIState();
}
break;
}
};
stream.onerror = () => {
log("Telemetry stream disconnected unexpectedly.", "error");
stream.close();
resetUIState();
};
function processOutputs(data) {
const audioFound = [];
const midiFound = [];
const search = (obj) => {
if (!obj) return;
if (Array.isArray(obj)) obj.forEach(search);
else if (typeof obj === 'object') {
if (obj.orig_name && obj.path) {
if (obj.orig_name.match(/\.(wav|mp3|flac|ogg)$/i)) {
audioFound.push({ name: obj.orig_name.split('.')[0], url: `${API_BASE}/file=${obj.path}` });
} else if (obj.orig_name.match(/\.(mid|midi)$/i)) {
midiFound.push({ name: obj.orig_name.split('.')[0], url: `${API_BASE}/file=${obj.path}` });
}
} else if (obj.name && obj.is_file) {
if (obj.name.match(/\.(wav|mp3|flac|ogg)$/i)) {
audioFound.push({ name: obj.name.split('/').pop().split('.')[0], url: `${API_BASE}/file=${obj.name}` });
} else if (obj.name.match(/\.(mid|midi)$/i)) {
midiFound.push({ name: obj.name.split('/').pop().split('.')[0], url: `${API_BASE}/file=${obj.name}` });
}
} else {
Object.values(obj).forEach(search);
}
}
};
search(data);
updateProgress(100);
// Merge Native MIDI with Audio Stems based on matching names
const mergedStems = audioFound.map(audio => {
const matchingMidi = midiFound.find(m => m.name === audio.name);
return { ...audio, midiUrl: matchingMidi ? matchingMidi.url : null };
});
log(`Extraction Complete. Found ${audioFound.length} audio stems, ${midiFound.length} native MIDI files.`, 'success');
if (mergedStems.length === 0) {
log("Payload analysis: " + JSON.stringify(data).substring(0, 150), "warning");
} else {
stems = mergedStems;
renderStems();
}
resetUIState(true);
}
function resetUIState(success = false) {
processBtn.innerText = success ? "Process Another" : "Error - Retry";
processBtn.disabled = false;
if(success) setTimeout(() => document.getElementById('progress-bar-container').classList.add('hidden'), 1000);
}
} catch(e) {
log(`FATAL: ${e.message}`, 'error');
processBtn.innerText = "Error - Retry";
processBtn.disabled = false;
document.getElementById('status-badge').classList.add('hidden');
}
});
// Minimal Binary MIDI Generator for client-side extraction fallback
function generateLocalMidi(audioElement, stemName) {
log(`Executing local Audio-to-MIDI analysis for [${stemName}]...`, 'warning');
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
fetch(audioElement.src)
.then(response => response.arrayBuffer())
.then(arrayBuffer => ctx.decodeAudioData(arrayBuffer))
.then(audioBuffer => {
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const threshold = 0.3; // Amplitude threshold for onset
const notes = [];
let isNoteOn = false;
// Basic onset detection
for (let i = 0; i < channelData.length; i++) {
if (Math.abs(channelData[i]) > threshold && !isNoteOn) {
notes.push(i / sampleRate);
isNoteOn = true;
} else if (Math.abs(channelData[i]) < 0.05 && isNoteOn) {
isNoteOn = false; // Reset for next transient
}
}
if (notes.length === 0) {
log(`Zero transients detected in [${stemName}]. MIDI export empty.`, 'error');
return;
}
// Build raw MIDI bytes (SMF Type 0)
const header = [0x4d, 0x54, 0x68, 0x64, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x01, 0x00, 0x60]; // 96 ticks per beat
let trackData = [];
let lastTick = 0;
notes.forEach(time => {
// Assuming 120 BPM, 2 beats per sec, 192 ticks per sec
const absoluteTick = Math.floor(time * 192);
let delta = absoluteTick - lastTick;
lastTick = absoluteTick;
// Write Variable Length Quantity (Delta)
const deltaBytes = writeVarLen(delta);
trackData.push(...deltaBytes);
// Note On, Channel 0, Note 60 (C4), Velocity 100
trackData.push(0x90, 0x3C, 0x64);
// Note Off immediately (percussion style)
trackData.push(0x00); // 0 delta
trackData.push(0x80, 0x3C, 0x00);
});
// End of Track
trackData.push(0x00, 0xFF, 0x2F, 0x00);
// Length of track chunk
const trkLen = trackData.length;
const trackHeader = [
0x4d, 0x54, 0x72, 0x6b,
(trkLen >> 24) & 0xff, (trkLen >> 16) & 0xff, (trkLen >> 8) & 0xff, trkLen & 0xff
];
const midiBytes = new Uint8Array([...header, ...trackHeader, ...trackData]);
const blob = new Blob([midiBytes], {type: "audio/midi"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${stemName}_generated.mid`;
a.click();
log(`MIDI generated and downloaded for [${stemName}]. Events: ${notes.length}`, 'success');
})
.catch(err => log(`MIDI processing failed: ${err.message}`, 'error'));
}
function writeVarLen(val) {
let buffer = [val & 0x7f];
while ((val >>= 7) > 0) buffer.unshift((val & 0x7f) | 0x80);
return buffer;
}
function renderStems() {
const container = document.getElementById('stems-container');
container.innerHTML = '';
audioElements = [];
document.getElementById('global-transport').classList.remove('hidden');
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioCtx = new AudioContext();
stems.forEach((stem, i) => {
const card = document.createElement('div');
card.className = "bg-black border border-zinc-800 p-4 rounded-sm flex flex-col gap-3 group relative";
const id = `stem-${i}`;
card.innerHTML = `
<div class="flex items-center justify-between">
<span class="text-xs font-bold uppercase tracking-widest text-zinc-300 group-hover:text-fuchsia-500 transition-colors">${stem.name}</span>
<div class="flex gap-2">
${stem.midiUrl
? `<a href="${stem.midiUrl}" download="${stem.name}.mid" class="text-[10px] font-mono bg-fuchsia-900/30 text-fuchsia-400 border border-fuchsia-900/50 px-2 py-0.5 rounded hover:bg-fuchsia-900 transition-colors">NATIVE MIDI</a>`
: `<button class="gen-midi-btn text-[10px] font-mono bg-zinc-900 text-zinc-400 border border-zinc-800 px-2 py-0.5 rounded hover:border-zinc-600 transition-colors" data-id="${id}" data-name="${stem.name}">GEN MIDI</button>`
}
<a href="${stem.url}" download="${stem.name}.wav" class="text-[10px] font-mono bg-zinc-900 text-zinc-300 border border-zinc-800 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors">WAV</a>
</div>
</div>
<div class="relative h-20 w-full bg-zinc-900 rounded-sm overflow-hidden border border-zinc-800">
<canvas id="canvas-${id}" class="absolute inset-0 w-full h-full"></canvas>
<div class="absolute bottom-1 right-1 text-[9px] font-mono text-zinc-600 bg-black/50 px-1">FFT + DATA</div>
<div id="stats-${id}" class="absolute top-1 left-1 flex flex-col gap-0.5"></div>
</div>
<audio id="audio-${id}" src="${stem.url}" crossorigin="anonymous" preload="auto"></audio>
<div class="flex items-center gap-2">
<button class="mute-btn text-zinc-500 hover:text-red-500 transition-colors" data-id="${id}">
<svg class="w-4 h-4 icon-vol" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5 10c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2l5 5V5L7 10H5z"></path></svg>
<svg class="w-4 h-4 icon-mute hidden text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h2.586l-1.293-1.293a1 1 0 011.414-1.414L15.414 14l-8.121 8.121a1 1 0 01-1.414-1.414L5.586 15z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path></svg>
</button>
<input type="range" class="vol-slider w-full h-1 bg-zinc-800 appearance-none [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2 [&::-webkit-slider-thumb]:bg-zinc-500 [&::-webkit-slider-thumb]:rounded-sm cursor-pointer" min="0" max="1" step="0.01" value="1" data-id="${id}">
</div>
`;
container.appendChild(card);
const audioEl = document.getElementById(`audio-${id}`);
const canvasEl = document.getElementById(`canvas-${id}`);
audioElements.push(audioEl);
if (i === 0) {
audioEl.addEventListener('loadedmetadata', () => {
masterDuration = audioEl.duration;
document.getElementById('master-scrubber').max = masterDuration;
document.getElementById('time-total').innerText = formatTime(masterDuration);
});
}
initVisualizer(audioCtx, audioEl, canvasEl, document.getElementById(`stats-${id}`));
card.querySelector('.mute-btn').addEventListener('click', (e) => {
const btn = e.currentTarget;
audioEl.muted = !audioEl.muted;
btn.querySelector('.icon-vol').classList.toggle('hidden');
btn.querySelector('.icon-mute').classList.toggle('hidden');
});
card.querySelector('.vol-slider').addEventListener('input', (e) => audioEl.volume = e.target.value);
const genMidiBtn = card.querySelector('.gen-midi-btn');
if(genMidiBtn) {
genMidiBtn.addEventListener('click', (e) => generateLocalMidi(audioEl, e.target.dataset.name));
}
});
}
document.getElementById('master-play-btn').addEventListener('click', () => {
isPlaying = !isPlaying;
document.getElementById('play-icon').classList.toggle('hidden');
document.getElementById('pause-icon').classList.toggle('hidden');
if (isPlaying) {
audioElements.forEach(a => a.play().catch(e => log(`Playback blocked: ${e.message}`, 'error')));
syncScrubber();
} else {
audioElements.forEach(a => a.pause());
cancelAnimationFrame(animationFrameId);
}
});
document.getElementById('master-scrubber').addEventListener('input', (e) => {
const time = parseFloat(e.target.value);
audioElements.forEach(a => a.currentTime = time);
document.getElementById('time-current').innerText = formatTime(time);
});
function syncScrubber() {
if (!isPlaying || !audioElements[0]) return;
const current = audioElements[0].currentTime;
document.getElementById('master-scrubber').value = current;
document.getElementById('time-current').innerText = formatTime(current);
animationFrameId = requestAnimationFrame(syncScrubber);
}
function formatTime(sec) {
if (isNaN(sec)) return "0:00";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60).toString().padStart(2, '0');
return `${m}:${s}`;
}
function initVisualizer(ctx, audio, canvas, statsEl) {
const canvasCtx = canvas.getContext("2d");
let source = null;
let analyser = null;
let dataArray = null;
// Generate initial stats
fetch(audio.src, {method: 'HEAD'}).then(res => {
const size = (res.headers.get('content-length') / 1024 / 1024).toFixed(2);
statsEl.innerHTML = `<span class="text-[8px] font-mono text-zinc-500 bg-black/80 px-1 w-max">SIZE: ${size} MB</span>`;
}).catch(() => {});
audio.addEventListener('play', () => {
if(ctx.state === 'suspended') ctx.resume();
if(!source) {
source = ctx.createMediaElementSource(audio);
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
analyser.connect(ctx.destination);
dataArray = new Uint8Array(analyser.frequencyBinCount);
}
draw();
});
function draw() {
if (audio.paused) return;
requestAnimationFrame(draw);
const width = canvas.width = canvas.clientWidth;
const height = canvas.height = canvas.clientHeight;
analyser.getByteFrequencyData(dataArray);
canvasCtx.clearRect(0, 0, width, height);
const barWidth = (width / analyser.frequencyBinCount) * 2.5;
let x = 0;
let peak = 0;
for(let i = 0; i < analyser.frequencyBinCount; i++) {
if (dataArray[i] > peak) peak = dataArray[i];
const barHeight = (dataArray[i] / 255) * height;
canvasCtx.fillStyle = `rgb(${dataArray[i]}, 70, 239)`;
canvasCtx.fillRect(x, height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
// Live Peak UI
if(statsEl.children.length > 1) statsEl.children[1].remove();
statsEl.innerHTML += `<span class="text-[8px] font-mono ${peak > 250 ? 'text-red-500' : 'text-zinc-400'} bg-black/80 px-1 w-max">PEAK: ${(peak/255).toFixed(2)}</span>`;
}
}
</script>
</body>
</html>