MiniMax-Music3-Jam / index.html
victor's picture
victor HF Staff
Header: MiniMax Music 3 | jam lockup per reference, drop example chips, 'like the model' line
ef88a79
Raw
History Blame Contribute Delete
52.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MiniMax-Music3-Jam</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&family=Outfit:wght@500&display=swap" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/mp4-muxer@5.1.3/build/mp4-muxer.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: oklch(0.13 0.006 260);
--surface: rgba(255, 255, 255, 0.04);
--surface-2: rgba(255, 255, 255, 0.06);
--surface-3: rgba(255, 255, 255, 0.09);
--border: rgba(255, 255, 255, 0.08);
--accent: oklch(0.90 0.005 260);
--accent-dim: oklch(0.40 0.005 260);
--accent-glow: rgba(255, 255, 255, 0.08);
--progress: oklch(0.72 0.14 155);
--text: rgba(255, 255, 255, 0.87);
--text-muted: rgba(255, 255, 255, 0.40);
--text-faint: rgba(255, 255, 255, 0.25);
--error: oklch(0.68 0.19 22);
--success: oklch(0.72 0.14 155);
--radius: 8px;
--radius-lg: 10px;
--font: 'Hanken Grotesk', system-ui, sans-serif;
}
html, body {
height: 100%; background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 13.5px; line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body { display: flex; flex-direction: column; min-height: 100vh; }
/* ── Layout ───────────────────────────────────────────────────────────── */
.page { display: flex; flex: 1; max-width: 1100px; width: 100%; margin: 0 auto; padding: 28px 20px 0; gap: 20px; align-items: flex-start; }
.col-left { flex: 1; min-width: 0; max-width: 480px; position: sticky; top: 0; background: var(--bg); }
.col-right { flex: 1; min-width: 0; }
/* ── Header ───────────────────────────────────────────────────────────── */
header { padding: 0 0 20px; text-align: left; }
/* Wordmark geometry is derived from the logo PNG (1200x224: text rows 65-152, text runs to the
right edge) and the reference lockup: divider 1/4 mark-height from the "3" and from "jam",
divider = 0.56 mark-height tall, "jam" x-height = 0.75 x "Music 3" cap height, same baseline. */
.wordmark { --h: 26px; display: inline-block; line-height: 0; white-space: nowrap; margin-bottom: 6px; }
.wordmark-logo {
height: var(--h); width: auto; display: inline-block;
vertical-align: calc(var(--h) * -0.321); /* put the PNG's text baseline on the line baseline */
}
.wordmark-divider {
display: inline-block; width: 1px; height: calc(var(--h) * 0.56);
background: rgb(148, 148, 149); vertical-align: calc(var(--h) * -0.125);
margin-left: calc(var(--h) * 0.25); /* the PNG has no right padding: the "3" ends at its edge */
margin-right: calc(var(--h) * 0.25);
}
.wordmark-jam {
display: inline-block; font-family: 'Outfit', 'Hanken Grotesk', system-ui, sans-serif;
font-size: calc(var(--h) * 0.62); font-weight: 500; letter-spacing: 0.11em; line-height: 1;
vertical-align: baseline; text-decoration: none;
background: linear-gradient(90deg, #d42a68 0%, #dd4b5a 50%, #ef7640 100%);
-webkit-background-clip: text; background-clip: text; color: transparent;
transition: opacity 0.15s;
}
.wordmark-jam:hover { opacity: 0.8; }
header p { font-size: 14px; color: var(--text-muted); }
/* ── Compose box ─────────────────────────────────────────────────────── */
.compose-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); margin-bottom: 10px;
transition: border-color 0.15s;
}
.compose-box:focus-within { border-color: rgba(255, 255, 255, 0.14); }
.compose-box #description-input {
background: transparent; border: none; border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 14px 14px 6px; height: 80px;
}
.compose-box #description-input:focus,
.compose-box #description-input:focus-visible { border: none; outline: none; }
.compose-controls {
display: flex; gap: 4px; padding: 4px 10px 10px;
align-items: center; flex-wrap: wrap;
}
.compose-controls .pill {
background: transparent; border: none; padding: 4px 8px;
font-size: 11px; border-radius: 6px;
}
.compose-controls .pill:hover { background: var(--surface-2); }
.compose-controls .pill select { font-size: 11px; min-width: 44px; background: transparent; border: none; }
.compose-controls .pill input[type="checkbox"] { width: 11px; height: 11px; }
.compose-controls .seed-input {
width: 48px !important; font-size: 11px !important; padding: 3px 6px !important;
background: transparent !important; border-radius: 6px !important;
}
.compose-controls .generate-btn {
width: auto; padding: 5px 14px; font-size: 12px;
border-radius: 6px; letter-spacing: 0;
}
/* ── Inputs ───────────────────────────────────────────────────────────── */
textarea, input[type="text"], input[type="number"], select {
width: 100%; background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); font-family: var(--font);
font-size: 13.5px; padding: 9px 11px; outline: none; transition: border-color 0.15s;
}
textarea:focus, input:focus, select:focus { border-color: rgba(255, 255, 255, 0.18); }
textarea:focus-visible, input:focus-visible, select:focus-visible { outline: 1px solid rgba(255, 255, 255, 0.08); outline-offset: 1px; }
textarea::placeholder, input::placeholder { color: var(--text-faint); }
textarea { resize: none; }
#description-input { height: 76px; font-size: 14px; }
.prompt-hint { margin-top: 6px; font-size: 11px; color: var(--text-faint); }
/* ── Controls row ─────────────────────────────────────────────────────── */
.controls-row { display: flex; gap: 6px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
.pill {
display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: var(--text-muted); cursor: pointer; user-select: none;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 6px 10px; white-space: nowrap;
transition: border-color 0.15s;
}
.pill:hover { border-color: rgba(255, 255, 255, 0.12); }
.pill input[type="checkbox"] { width: 12px; height: 12px; accent-color: var(--accent); cursor: pointer; }
.pill:has(input:checked) span { color: var(--text); }
.pill select { background: transparent; border: none; color: var(--text); font-size: 12px; padding: 0; min-width: 56px; }
.seed-input { width: 72px !important; font-size: 12px !important; padding: 6px 8px !important; }
.spacer { flex: 1; }
/* ── Generate button ──────────────────────────────────────────────────── */
.generate-btn {
width: 100%; background: var(--accent); border: none;
border-radius: var(--radius); color: oklch(0.13 0.006 260); font-family: var(--font);
font-size: 14px; font-weight: 600; padding: 11px; cursor: pointer;
transition: opacity 0.15s, transform 0.1s; letter-spacing: -0.01em;
}
.generate-btn:hover:not(:disabled) { opacity: 0.88; }
.generate-btn:active:not(:disabled) { transform: scale(0.99); }
.generate-btn:disabled { background: var(--accent-dim); cursor: not-allowed; opacity: 0.5; }
.generate-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.generate-btn.loading { animation: btn-pulse 2s ease-in-out infinite; }
@keyframes btn-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ── Status ───────────────────────────────────────────────────────────── */
.status-bar { font-size: 11.5px; color: var(--text-muted); text-align: center; padding: 0; min-height: 0; display: none; }
.status-bar.error { display: block; color: var(--error); padding: 6px 0; }
.status-bar.success { display: none; }
/* ── Output card ──────────────────────────────────────────────────────── */
.output-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 14px; margin-bottom: 10px;
animation: fade-in 0.25s ease;
}
@keyframes fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
#waveform {
display: flex; align-items: end; gap: 1px; height: 56px;
background: var(--surface-2); border-radius: var(--radius);
padding: 8px 10px; cursor: pointer; margin-bottom: 10px;
}
#waveform .bar {
flex: 1; background: var(--text-faint); border-radius: 1px;
transition: background 0.15s;
}
.playback-controls { display: flex; align-items: center; gap: 10px; }
.play-btn {
width: 30px; height: 30px; background: var(--accent); border: none;
border-radius: 50%; color: oklch(0.13 0.006 260); font-size: 12px; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: opacity 0.15s;
}
.play-btn:hover { opacity: 0.85; }
.play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.time-display { font-size: 11.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex: 1; }
.download-btn {
display: inline-flex; align-items: center; gap: 4px; background: #fff;
border: none; border-radius: 6px; color: #111;
font-family: var(--font); font-size: 11px;
font-weight: 600; padding: 5px 10px; cursor: pointer;
transition: opacity 0.15s;
}
.download-btn:hover { opacity: 0.85; }
.download-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.download-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.share-icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px; background: #fff; border: none; border-radius: 3px;
color: #111; font-size: 10px; cursor: pointer; flex-shrink: 0; margin-left: 4px;
transition: opacity 0.15s;
}
.share-icon-btn:hover { opacity: 0.85; }
.share-icon-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.share-icon-btn svg { width: 9px; height: 9px; }
/* Video generation overlay */
.video-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.85);
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 100; gap: 12px;
}
.video-overlay .label { font-size: 13px; color: var(--text-muted); }
.video-overlay .pct { font-size: 28px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.video-overlay .bar-track { width: 200px; height: 3px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden; }
.video-overlay .bar-fill { height: 100%; background: #fff; border-radius: 2px; transition: width 0.3s; }
/* ── Community feed ───────────────────────────────────────────────────── */
.feed-header {
font-size: 11px; font-weight: 600; color: var(--text-muted);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 10px;
}
.feed-empty { font-size: 12px; color: var(--text-faint); text-align: center; padding: 32px 0; }
.song-card {
background: transparent; border: 1px solid transparent;
border-radius: var(--radius); padding: 8px 10px; margin-bottom: 6px;
cursor: pointer; transition: background 0.15s;
}
.song-card:hover { background: var(--surface); }
.song-card.playing { background: var(--surface); }
.song-card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.song-play-btn {
width: 26px; height: 26px; background: var(--surface-3); border: 1px solid var(--border);
border-radius: 50%; color: var(--text-muted); font-size: 10px; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.song-play-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.song-card:hover .song-play-btn { background: rgba(255, 255, 255, 0.08); color: var(--text); border-color: rgba(255, 255, 255, 0.12); }
.song-card.playing .song-play-btn { background: var(--accent); color: oklch(0.13 0.006 260); border-color: var(--accent); }
.song-title { font-size: 13px; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.song-tags { font-size: 11px; color: var(--text-muted); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.song-duration { font-size: 11px; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; margin-left: 8px; }
/* ── Placeholder (loading state) ──────────────────────────────────────── */
.placeholder-card {
background: var(--surface); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-lg); padding: 14px;
margin-bottom: 10px; animation: fade-in 0.25s ease;
}
.placeholder-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 3px; }
.placeholder-sub { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; }
.fake-waveform {
display: flex; align-items: end; gap: 2px; height: 44px;
background: var(--surface-2); border-radius: var(--radius); padding: 8px 10px;
}
.fake-waveform .bar {
flex: 1; background: var(--accent); border-radius: 1px; opacity: 0.3;
animation: wave-pulse 1.2s ease-in-out infinite;
}
@keyframes wave-pulse {
0%, 100% { transform: scaleY(0.3); opacity: 0.15; }
50% { transform: scaleY(1); opacity: 0.4; }
}
/* ── Mini waveform ────────────────────────────────────────────────────── */
.mini-waveform {
display: flex; align-items: end; gap: 1px; height: 22px; margin-top: 5px;
cursor: pointer;
}
.mini-waveform .bar {
flex: 1; background: var(--text-faint); border-radius: 1px;
transition: background 0.2s;
}
.song-card.playing .mini-waveform .bar { background: var(--accent); }
.song-card:hover .mini-waveform .bar { background: rgba(255, 255, 255, 0.22); }
/* ── Spinner ──────────────────────────────────────────────────────────── */
.spinner {
display: inline-block; width: 12px; height: 12px;
border: 2px solid rgba(0, 0, 0, 0.12); border-top-color: oklch(0.13 0.006 260);
border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Footer ───────────────────────────────────────────────────────────── */
footer { padding: 20px; text-align: center; font-size: 11px; color: var(--text-faint); }
footer a { color: var(--text-muted); text-decoration: none; }
footer a:hover { color: var(--text); }
/* ── Upsell ───────────────────────────────────────────────────────────── */
.upsell {
font-size: 11px; color: var(--text-faint); text-align: center;
margin: 6px 0 2px; padding: 0;
}
.upsell a { color: var(--text-muted); text-decoration: none; border-bottom: 1px dotted var(--border); }
.upsell a:hover { color: var(--text); border-bottom-color: var(--text-muted); }
.upsell.hidden { display: none; }
.upsell .heart-icon { width: 11px; height: 11px; vertical-align: -1px; margin-left: 1px; }
/* ── Dates & lyrics ───────────────────────────────────────────────────── */
.song-meta-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.song-date { font-size: 11px; color: var(--text-faint); font-variant-numeric: tabular-nums; white-space: nowrap; }
.lyrics-toggle {
background: transparent; border: none; padding: 0; color: var(--text-faint);
font-family: var(--font); font-size: 11px; cursor: pointer; text-decoration: underline dotted;
text-underline-offset: 2px;
}
.lyrics-toggle:hover, .lyrics-toggle.open { color: var(--text); }
.lyrics-box {
font-family: var(--font); font-size: 12px; line-height: 1.55; color: var(--text-muted);
white-space: pre-wrap; background: var(--surface-2); border-radius: var(--radius);
padding: 10px 12px; margin: 0 0 10px; max-height: 240px; overflow: auto;
}
.lyrics-box[hidden] { display: none; }
.song-card .lyrics-box { margin: 8px 0 2px; max-height: 200px; }
.song-card-bottom { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.song-card-bottom .song-date { flex: 1; }
/* ── Mobile ───────────────────────────────────────────────────────────── */
.share-label-short { display: none; }
@media (max-width: 800px) {
.page { flex-direction: column; padding: 20px 16px 0; gap: 16px; }
.col-left, .col-right { max-width: none; width: 100%; }
.song-card-top .song-tags { display: none; }
.controls-row { gap: 5px; }
.seed-input { width: 60px !important; }
.share-label-full { display: none; }
.share-label-short { display: inline; }
}
</style>
</head>
<body>
<div class="page">
<!-- ════ LEFT: Create ════ -->
<div class="col-left">
<header>
<div class="wordmark">
<img class="wordmark-logo" src="logo.png" alt="MiniMax Music 3" /><span class="wordmark-divider" aria-hidden="true"></span><a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank" class="wordmark-jam">jam</a>
</div>
<p>Describe any song. An LLM writes it, MiniMax Music 3 sings it.</p>
</header>
<div class="compose-box">
<textarea id="description-input"
placeholder="A dreamy synthwave song about a night drive through a neon city, female vocals…"
></textarea>
<div class="compose-controls">
<label class="pill">
<select id="duration-select">
<option value="30">30s</option>
<option value="60" selected>1 min</option>
<option value="120">2 min</option>
</select>
</label>
<label class="pill"><input type="checkbox" id="instrumental-check" /><span>Instrumental</span></label>
<label class="pill"><input type="checkbox" id="community-check" checked /><span class="share-label-full">Share in community</span><span class="share-label-short">Share</span></label>
<span class="spacer"></span>
<button class="generate-btn" id="generate-btn">Generate</button>
</div>
</div>
<p class="upsell" id="upsell-studio">
Don't forget to
<a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank" rel="noopener">like the model <svg class="heart-icon" viewBox="0 0 24 24" fill="#e53935" aria-hidden="true"><path d="M12 21s-7.09-4.35-9.64-8.36C.66 10.02 1.24 6.6 4.1 5.02c2.2-1.22 4.9-.63 6.4 1.18.6.72 1.02 1.55 1.5 1.55s.9-.83 1.5-1.55c1.5-1.81 4.2-2.4 6.4-1.18 2.86 1.58 3.44 5 1.74 7.62C19.09 16.65 12 21 12 21z"/></svg></a>
</p>
<div class="status-bar" id="status-bar"></div>
<!-- Output -->
<div id="output-section" style="display:none">
<div class="output-card">
<div style="display:flex;gap:10px;margin-bottom:8px;">
<img id="song-thumb" style="width:48px;height:48px;border-radius:6px;object-fit:cover;display:none;" />
<div style="min-width:0;flex:1;">
<div id="song-title" style="font-size:13px;font-weight:600;margin-bottom:3px;"></div>
<div id="song-tags" style="font-size:11px;color:var(--text-muted);"></div>
<div class="song-meta-row"><span id="song-date" class="song-date"></span><button class="lyrics-toggle" id="lyrics-toggle" type="button">Lyrics</button></div>
</div>
</div>
<pre class="lyrics-box" id="lyrics-box" hidden></pre>
<div id="waveform"></div>
<div class="playback-controls">
<button class="play-btn" id="play-btn" title="Play / Pause"><svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg></button>
<span class="time-display" id="time-display">0:00 / 0:00</span>
<button class="download-btn" id="download-btn">⬇ Download</button>
</div>
</div>
</div>
</div>
<!-- ════ RIGHT: Community feed ════ -->
<div class="col-right">
<div class="feed-header">Community Songs</div>
<div id="feed"></div>
</div>
</div>
<footer>
Powered by <a href="https://huggingface.co/MiniMaxAI/MiniMax-Music3" target="_blank">MiniMax Music 3</a> Β·
<a href="https://huggingface.co/spaces/multimodalart/minimax-music3-prompting-guide" target="_blank">Prompting guide</a> Β·
<a href="https://huggingface.co/spaces/MiniMaxAI/MiniMax-Music3" target="_blank">Official Studio</a> Β·
<a href="https://huggingface.co/buckets/victor/minimax-music3-community" target="_blank">Community bucket</a>
</footer>
<script type="module">
// ── ZeroGPU auth ──────────────────────────────────────────────────────────
const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers";
window.addEventListener("message", (e) => { if (e.data === ZEROGPU_HEADERS_MSG) window.supports_zerogpu_headers = true; });
const _hn = window.location.hostname;
if (_hn.endsWith(".hf.space") || _hn.includes(".dev.")) {
const _o = _hn.includes(".dev.") ? `https://moon-${_hn.split(".")[1]}.dev.spaces.huggingface.tech` : "https://huggingface.co";
window.parent.postMessage(ZEROGPU_HEADERS_MSG, _o);
}
import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client@1.15.2/dist/index.min.js";
// WaveSurfer removed β€” using lightweight bar waveform with real audio peaks
// ── DOM ──────────────────────────────────────────────────────────────────
const descInput = document.getElementById("description-input");
const durationSelect = document.getElementById("duration-select");
const instrumentalChk = document.getElementById("instrumental-check");
const communityChk = document.getElementById("community-check");
const generateBtn = document.getElementById("generate-btn");
const statusBar = document.getElementById("status-bar");
const outputSection = document.getElementById("output-section");
const songTitleEl = document.getElementById("song-title");
const songTagsEl = document.getElementById("song-tags");
const playBtn = document.getElementById("play-btn");
const timeDisplay = document.getElementById("time-display");
const downloadBtn = document.getElementById("download-btn");
const feedEl = document.getElementById("feed");
const songDateEl = document.getElementById("song-date");
const lyricsToggle = document.getElementById("lyrics-toggle");
const lyricsBox = document.getElementById("lyrics-box");
// ── Dates ────────────────────────────────────────────────────────────────
function fmtDate(iso) {
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return "";
const diff = (Date.now() - d.getTime()) / 1000;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 7 * 86400) return `${Math.floor(diff / 86400)}d ago`;
const opts = { month: "short", day: "numeric" };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = "numeric";
return d.toLocaleDateString(undefined, opts);
}
function fullDate(iso) {
const d = new Date(iso);
return isNaN(d) ? "" : d.toLocaleString();
}
// ── Lyrics toggles ───────────────────────────────────────────────────────
function wireLyricsToggle(btn, box, lyrics) {
if (!lyrics || !lyrics.trim()) { btn.style.display = "none"; box.hidden = true; return; }
btn.style.display = "";
box.textContent = lyrics.trim();
box.hidden = true;
btn.classList.remove("open");
btn.onclick = (e) => {
e.stopPropagation();
box.hidden = !box.hidden;
btn.classList.toggle("open", !box.hidden);
};
}
downloadBtn.addEventListener("click", () => {
if (currentSong) generateShareVideo(currentSong, downloadBtn);
});
// ── Main player (plain Audio + bar waveform) ─────────────────────────────
const mainAudio = new Audio();
const waveformEl = document.getElementById("waveform");
function fmt(s) { return `${Math.floor(s/60)}:${Math.floor(s%60).toString().padStart(2,"0")}`; }
function updateTime() {
const c = mainAudio.currentTime || 0, d = mainAudio.duration || 0;
timeDisplay.textContent = `${fmt(c)} / ${fmt(d)}`;
}
function updateMainProgress() {
if (mainAudio.paused && mainAudio.currentTime === 0) return;
const pct = mainAudio.duration ? mainAudio.currentTime / mainAudio.duration : 0;
waveformEl.querySelectorAll(".bar").forEach((b, i, arr) => {
b.style.background = (i / arr.length) <= pct ? "var(--accent)" : "";
});
updateTime();
if (!mainAudio.paused) requestAnimationFrame(updateMainProgress);
}
const playIcon = '<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>';
const pauseIcon = '<svg width="10" height="12" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>';
mainAudio.addEventListener("play", () => { playBtn.innerHTML = pauseIcon; requestAnimationFrame(updateMainProgress); });
mainAudio.addEventListener("pause", () => { playBtn.innerHTML = playIcon; });
mainAudio.addEventListener("ended", () => { playBtn.innerHTML = playIcon; waveformEl.querySelectorAll(".bar").forEach(b => b.style.background = ""); });
mainAudio.addEventListener("timeupdate", updateTime);
playBtn.addEventListener("click", () => { stopFeedAudio(); mainAudio.paused ? mainAudio.play() : mainAudio.pause(); });
waveformEl.addEventListener("click", (e) => {
const rect = waveformEl.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
if (mainAudio.duration) {
stopFeedAudio();
mainAudio.currentTime = mainAudio.duration * ratio;
if (mainAudio.paused) mainAudio.play();
requestAnimationFrame(updateMainProgress);
}
});
// ── Current song state (for video generation) ────────────────────────────
let currentSong = null;
// ── Fast video generation (WebCodecs + MP4 muxer) ─────────────────────
const { Muxer: Mp4MuxerClass, ArrayBufferTarget } = Mp4Muxer;
async function generateShareVideo(song, btn) {
const origHTML = btn.innerHTML;
btn.disabled = true;
btn.textContent = "…";
stopFeedAudio();
mainAudio.pause();
const overlay = document.createElement("div");
overlay.className = "video-overlay";
overlay.innerHTML = `
<div class="label">Generating video…</div>
<div class="pct" id="vid-pct">0%</div>
<div class="bar-track"><div class="bar-fill" id="vid-bar" style="width:0%"></div></div>
`;
document.body.appendChild(overlay);
const pctEl = overlay.querySelector("#vid-pct");
const barEl = overlay.querySelector("#vid-bar");
try {
await document.fonts.ready;
const audioResp = await fetch(song.audio_url);
const audioArrayBuf = await audioResp.arrayBuffer();
const offCtx = new OfflineAudioContext(2, 1, 44100);
const decoded = await offCtx.decodeAudioData(audioArrayBuf);
const sampleRate = decoded.sampleRate;
const numChannels = decoded.numberOfChannels;
const audioLength = decoded.length;
const raw = decoded.getChannelData(0);
const numBars = 60;
const blockSize = Math.floor(raw.length / numBars);
const peaks = [];
for (let i = 0; i < numBars; i++) {
let sum = 0;
for (let j = 0; j < blockSize; j++) sum += Math.abs(raw[i * blockSize + j]);
peaks.push(sum / blockSize);
}
const mx = Math.max(...peaks);
const normPeaks = peaks.map(p => mx > 0 ? p / mx : 0.5);
let thumbImg = null;
if (song.thumb_url) {
thumbImg = new Image();
thumbImg.crossOrigin = "anonymous";
await new Promise(r => { thumbImg.onload = r; thumbImg.onerror = r; thumbImg.src = song.thumb_url; });
}
const W = 1080, H = 1080;
const canvas = document.createElement("canvas");
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const duration = decoded.duration;
const FPS = 15;
const totalFrames = Math.ceil(duration * FPS);
function drawFrame(progress) {
ctx.fillStyle = "#111111";
ctx.fillRect(0, 0, W, H);
const topY = 120;
if (thumbImg && thumbImg.naturalWidth > 0) {
ctx.save();
ctx.beginPath();
ctx.roundRect(90, topY, 120, 120, 14);
ctx.clip();
ctx.drawImage(thumbImg, 90, topY, 120, 120);
ctx.restore();
}
const textX = thumbImg && thumbImg.naturalWidth > 0 ? 230 : 90;
ctx.fillStyle = "#ffffff";
ctx.font = "600 36px 'Hanken Grotesk', sans-serif";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
let title = song.title || "Untitled";
while (ctx.measureText(title).width > W - textX - 90 && title.length > 3) title = title.slice(0, -1);
if (title !== song.title) title += "…";
ctx.fillText(title, textX, topY + 45);
ctx.fillStyle = "rgba(255,255,255,0.45)";
ctx.font = "400 22px 'Hanken Grotesk', sans-serif";
let tags = song.tags || "";
while (ctx.measureText(tags).width > W - textX - 90 && tags.length > 3) tags = tags.slice(0, -1);
if (tags !== song.tags) tags += "…";
ctx.fillText(tags, textX, topY + 90);
const waveY = 440, waveH = 220, pad = 80;
const totalW = W - pad * 2;
const gap = 3;
const barW = (totalW - (numBars - 1) * gap) / numBars;
for (let i = 0; i < numBars; i++) {
const x = pad + i * (barW + gap);
const h = Math.max(4, normPeaks[i] * waveH);
ctx.fillStyle = (i / numBars) <= progress ? "rgba(255,255,255,0.92)" : "rgba(255,255,255,0.15)";
ctx.beginPath();
ctx.roundRect(x, waveY + (waveH - h) / 2, barW, h, Math.min(barW / 2, 3));
ctx.fill();
}
ctx.textAlign = "center";
ctx.fillStyle = "rgba(255,255,255,0.40)";
ctx.font = "400 36px 'Hanken Grotesk', sans-serif";
ctx.fillText("Create your own song:", W / 2, 880);
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.font = "500 33px 'Hanken Grotesk', sans-serif";
ctx.fillText("hf.co/spaces/victor/MiniMax-Music3-Jam", W / 2, 930);
ctx.textAlign = "left";
}
// ── Encode with WebCodecs + MP4 muxer ──
const target = new ArrayBufferTarget();
const muxer = new Mp4MuxerClass({
target,
video: { codec: "avc", width: W, height: H },
audio: { codec: "aac", sampleRate, numberOfChannels: numChannels },
firstTimestampBehavior: "offset",
fastStart: "in-memory",
});
const audioEncoder = new AudioEncoder({
output: (chunk, meta) => muxer.addAudioChunk(chunk, meta),
error: e => console.error("AudioEncoder error:", e),
});
audioEncoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: numChannels, bitrate: 128_000 });
const audioChunkSize = 4096;
for (let offset = 0; offset < audioLength; offset += audioChunkSize) {
const len = Math.min(audioChunkSize, audioLength - offset);
const audioData = new AudioData({
format: "f32-planar", sampleRate, numberOfFrames: len, numberOfChannels: numChannels,
timestamp: (offset / sampleRate) * 1_000_000,
data: (() => {
const buf = new Float32Array(len * numChannels);
for (let ch = 0; ch < numChannels; ch++) {
buf.set(decoded.getChannelData(ch).subarray(offset, offset + len), ch * len);
}
return buf;
})(),
});
audioEncoder.encode(audioData);
audioData.close();
}
await audioEncoder.flush();
const videoEncoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: e => console.error("VideoEncoder error:", e),
});
videoEncoder.configure({ codec: "avc1.640028", width: W, height: H, bitrate: 2_000_000, framerate: FPS });
let frameIdx = 0;
const batchSize = 5;
await new Promise((resolve) => {
function processBatch() {
const end = Math.min(frameIdx + batchSize, totalFrames);
for (; frameIdx < end; frameIdx++) {
const progress = frameIdx / totalFrames;
drawFrame(progress);
const frame = new VideoFrame(canvas, { timestamp: (frameIdx / FPS) * 1_000_000 });
videoEncoder.encode(frame, { keyFrame: frameIdx % (FPS * 2) === 0 });
frame.close();
}
pctEl.textContent = `${Math.round((frameIdx / totalFrames) * 100)}%`;
barEl.style.width = `${Math.round((frameIdx / totalFrames) * 100)}%`;
if (frameIdx < totalFrames) setTimeout(processBatch, 0);
else {
videoEncoder.flush().then(() => {
muxer.finalize();
const blob = new Blob([target.buffer], { type: "video/mp4" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const safeName = (song.title || "song").replace(/[^a-zA-Z0-9 -]/g, "").trim().replace(/\s+/g, "-") || "song";
a.download = `${safeName}.mp4`;
a.click();
URL.revokeObjectURL(url);
resolve();
});
}
}
processBatch();
});
} catch (e) {
console.error("Video generation failed:", e);
} finally {
overlay.remove();
btn.disabled = false;
btn.innerHTML = origHTML;
}
}
// ── Gradio client ───────────────────────────────────────────────────────
let gradioClient = null;
async function getClient() {
if (!gradioClient) gradioClient = await Client.connect(window.location.origin);
return gradioClient;
}
// ── UI helpers ──────────────────────────────────────────────────────────
function setLoading(on) {
generateBtn.disabled = on;
if (on) { generateBtn.innerHTML = '<span class="spinner"></span> Creating…'; generateBtn.classList.add("loading"); }
else { generateBtn.textContent = "Generate"; generateBtn.classList.remove("loading"); }
if (on) document.getElementById("upsell-studio")?.classList.add("hidden");
}
function setStatus(msg, type = "") {
statusBar.textContent = msg;
statusBar.className = "status-bar" + (type ? ` ${type}` : "");
}
async function loadAudio(dataUrl) {
const res = await fetch(dataUrl);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
// Extract real peaks for bar waveform
const numBars = 80;
try {
const buf = await blob.arrayBuffer();
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const decoded = await ctx.decodeAudioData(buf);
const raw = decoded.getChannelData(0);
const block = Math.floor(raw.length / numBars);
const peaks = [];
for (let i = 0; i < numBars; i++) {
let sum = 0;
for (let j = 0; j < block; j++) sum += Math.abs(raw[i * block + j]);
peaks.push(sum / block);
}
const mx = Math.max(...peaks);
waveformEl.innerHTML = peaks.map(p => {
const h = mx > 0 ? 15 + (p / mx) * 85 : 50;
return `<div class="bar" style="height:${h}%"></div>`;
}).join("");
} catch {
waveformEl.innerHTML = Array.from({length: numBars}, () =>
`<div class="bar" style="height:${15 + Math.random() * 85}%"></div>`
).join("");
}
mainAudio.src = url;
currentSong = { ...currentSong, audio_url: url };
outputSection.style.display = "block";
document.getElementById("upsell-studio")?.classList.add("hidden");
stopFeedAudio();
mainAudio.play();
}
// ── Community feed player ───────────────────────────────────────────────
let feedAudio = null;
let playingCard = null;
function stopFeedAudio() {
if (feedAudio) { feedAudio.pause(); feedAudio = null; }
if (playingCard) {
playingCard.querySelectorAll(".mini-waveform .bar").forEach(b => b.style.background = "");
playingCard.classList.remove("playing");
playingCard.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>';
playingCard = null;
}
}
function playFeedSong(card, audioUrl, seekRatio) {
// If same card and already playing, just seek (or pause if clicking play btn)
if (playingCard === card && feedAudio) {
if (seekRatio != null) {
feedAudio.currentTime = feedAudio.duration * seekRatio;
if (feedAudio.paused) feedAudio.play();
} else {
// Toggle play/pause (clicked the play button)
if (feedAudio.paused) { feedAudio.play(); }
else { feedAudio.pause(); }
}
return;
}
stopFeedAudio();
mainAudio.pause();
feedAudio = new Audio(audioUrl);
playingCard = card;
card.classList.add("playing");
card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>';
feedAudio.play();
if (seekRatio != null) {
feedAudio.addEventListener("loadedmetadata", () => {
feedAudio.currentTime = feedAudio.duration * seekRatio;
}, { once: true });
}
// Update waveform progress during playback
const waveEl = card.querySelector(".mini-waveform");
const bars = waveEl ? waveEl.querySelectorAll(".bar") : [];
function updateProgress() {
if (playingCard !== card || !feedAudio || feedAudio.paused) return;
const pct = feedAudio.currentTime / feedAudio.duration;
bars.forEach((b, i) => {
b.style.background = (i / bars.length) <= pct ? "var(--accent)" : "var(--text-faint)";
});
requestAnimationFrame(updateProgress);
}
if (bars.length) requestAnimationFrame(updateProgress);
feedAudio.addEventListener("ended", () => {
bars.forEach(b => b.style.background = "");
stopFeedAudio();
});
feedAudio.addEventListener("error", () => stopFeedAudio());
feedAudio.addEventListener("pause", () => {
if (playingCard === card) card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg>';
});
feedAudio.addEventListener("play", () => {
if (playingCard === card) {
card.querySelector(".song-play-btn").innerHTML = '<svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><rect x="0" y="0" width="3" height="12"/><rect x="7" y="0" width="3" height="12"/></svg>';
requestAnimationFrame(updateProgress);
}
});
}
// ── Load community feed ─────────────────────────────────────────────────
async function loadFeed() {
try {
const client = await getClient();
const res = await client.predict("/community");
// Gradio JS client may return {data: [...]} or the value directly
const raw = res?.data?.[0] ?? res?.data ?? res;
const songs = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw) || "[]");
if (songs.length === 0) {
feedEl.innerHTML = '<div class="feed-empty">No community songs yet.<br>Be the first: keep "Share" checked and generate!</div>';
return;
}
feedEl.innerHTML = "";
for (const song of songs) {
const card = document.createElement("div");
card.className = "song-card";
const dur = song.duration ? `${Math.round(song.duration)}s` : "";
const when = fmtDate(song.created_at);
// Generate random mini waveform bars (seeded from title for consistency)
const miniBars = Array.from({length: 80}, () =>
`<div class="bar" style="height:${15 + Math.random() * 85}%"></div>`
).join("");
const thumbHtml = song.thumb_url
? `<img src="${esc(song.thumb_url)}" style="width:24px;height:24px;border-radius:4px;object-fit:cover;flex-shrink:0;" loading="lazy" onerror="this.style.display='none'" />`
: '';
card.innerHTML = `
<div class="song-card-top">
<button class="song-play-btn"><svg width="8" height="10" viewBox="0 0 10 12" fill="currentColor"><path d="M0 0l10 6-10 6z"/></svg></button>
${thumbHtml}
<div class="song-title">${esc(song.title || "Untitled")}</div>
<div class="song-tags">${esc(song.tags || "")}</div>
<div class="song-duration">${dur}</div>
<button class="share-icon-btn" title="Download video"><svg viewBox="0 0 16 16" fill="currentColor"><path d="M3.5 13V8.5h1V12h7V8.5h1V13a.5.5 0 01-.5.5H4a.5.5 0 01-.5-.5zM8 10.25L4.75 7h2V2.5h2.5V7h2L8 10.25z"/></svg></button>
</div>
<div class="mini-waveform">${miniBars}</div>
<div class="song-card-bottom">
<span class="song-date" title="${esc(fullDate(song.created_at))}">${esc(when)}</span>
<button class="lyrics-toggle" type="button">Lyrics</button>
</div>
<pre class="lyrics-box" hidden></pre>
`;
wireLyricsToggle(card.querySelector(".lyrics-toggle"), card.querySelector(".lyrics-box"), song.lyrics || "");
card.querySelector(".lyrics-box").addEventListener("click", (e) => e.stopPropagation());
card.querySelector(".song-play-btn").addEventListener("click", (e) => {
e.stopPropagation();
playFeedSong(card, song.audio_url);
});
card.querySelector(".mini-waveform").addEventListener("click", (e) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
playFeedSong(card, song.audio_url, ratio);
});
card.querySelector(".share-icon-btn").addEventListener("click", (e) => {
e.stopPropagation();
generateShareVideo(song, e.currentTarget);
});
feedEl.appendChild(card);
}
} catch (e) {
console.error("Feed load error:", e);
feedEl.innerHTML = '<div class="feed-empty">Could not load community songs</div>';
}
}
function esc(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
// ── Generate ─────────────────────────────────────────────────────────────
generateBtn.addEventListener("click", async () => {
const desc = descInput.value.trim();
if (!desc) { setStatus("Describe the song you want to create", "error"); descInput.focus(); return; }
stopFeedAudio();
setLoading(true);
setStatus("");
// Show animated placeholder on the left, above output
outputSection.style.display = "none";
const placeholder = document.createElement("div");
placeholder.className = "placeholder-card";
placeholder.id = "gen-placeholder";
const bars = Array.from({length: 40}, (_, i) =>
`<div class="bar" style="height:100%;animation-delay:${(i * 0.06).toFixed(2)}s"></div>`
).join("");
placeholder.innerHTML = `
<div class="placeholder-title">${esc(desc.slice(0, 80))}</div>
<div class="placeholder-sub">Writing lyrics &amp; structured caption…</div>
<div class="fake-waveform">${bars}</div>
`;
outputSection.parentNode.insertBefore(placeholder, outputSection);
const phTitle = placeholder.querySelector(".placeholder-title");
const phSub = placeholder.querySelector(".placeholder-sub");
let etaTimer = null;
const stopEta = () => { if (etaTimer) { clearInterval(etaTimer); etaTimer = null; } };
const startEta = (eta, base) => {
stopEta();
const t0 = Date.now();
const tick = () => {
const left = Math.max(0, Math.round(eta - (Date.now() - t0) / 1000));
phSub.textContent = left > 0 ? `${base} ~${left}s left` : `${base} finishing up…`;
};
tick();
etaTimer = setInterval(tick, 1000);
};
try {
const client = await getClient();
const job = client.submit("/create", {
description: desc,
audio_duration: parseFloat(durationSelect.value),
seed: -1,
community: communityChk.checked,
instrumental: instrumentalChk.checked,
});
for await (const msg of job) {
if (msg.type === "status") {
if (msg.queue_size > 0) {
const eta = msg.eta != null ? ` Β· ~${Math.round(msg.eta)}s` : "";
setStatus(`Queue ${msg.position}/${msg.queue_size}${eta}`);
}
} else if (msg.type === "data") {
const raw = msg.data?.[0];
if (!raw) continue;
const result = typeof raw === "string" ? JSON.parse(raw) : raw;
if (result.status) {
// Intermediate progress message
if (result.title) phTitle.textContent = result.title;
if (result.status === "generating") {
startEta(Number(result.eta) || 60, result.message || "MiniMax Music 3 is recording…");
} else {
phSub.textContent = result.message || "Working…";
}
continue;
}
if (!result.audio) continue;
stopEta();
songTitleEl.textContent = result.title || "";
songTagsEl.textContent = result.tags || "";
songDateEl.textContent = fmtDate(result.created_at);
songDateEl.title = fullDate(result.created_at);
wireLyricsToggle(lyricsToggle, lyricsBox, result.lyrics || "");
const thumbEl = document.getElementById("song-thumb");
if (result.thumbnail) { thumbEl.src = result.thumbnail; thumbEl.style.display = "block"; }
else { thumbEl.style.display = "none"; }
currentSong = {
title: result.title || "Untitled",
tags: result.tags || "",
thumb_url: result.thumbnail || null,
};
await loadAudio(result.audio);
if (result.share_error) setStatus(result.share_error, "error");
// Refresh feed if shared
if (communityChk.checked) loadFeed();
}
}
} catch (err) {
console.error(err);
const m = (err && err.message) ? err.message.replace(/^Error:\s*/, "") : "Something went wrong";
setStatus(m, "error");
} finally {
stopEta();
placeholder.remove();
setLoading(false);
}
});
descInput.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); generateBtn.click(); }
});
// ── Init ─────────────────────────────────────────────────────────────────
getClient().then(() => loadFeed()).catch(console.error);
</script>
</body>
</html>