Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
43 lines
2.3 KiB
JavaScript
43 lines
2.3 KiB
JavaScript
import { $, asDataUrl, state, toast } from "./core.js";
|
|
import { wavBlob } from "./render.js";
|
|
import { askMash } from "./dj.js";
|
|
import { makeJob } from "./app.js";
|
|
const MASH_MAX = 3, MASH_SR = 22050;
|
|
async function mashReference(blobs, seconds) {
|
|
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null;
|
|
const probe = new AC(1, 1, MASH_SR);
|
|
const bufs = [];
|
|
for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} }
|
|
if (bufs.length < 2) return null;
|
|
const per = seconds / bufs.length, fade = 2, total = seconds;
|
|
const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR);
|
|
let at = 0;
|
|
bufs.forEach((buf, i) => {
|
|
const src = ctx.createBufferSource(); src.buffer = buf;
|
|
const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination);
|
|
const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade);
|
|
const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat
|
|
g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade);
|
|
g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len);
|
|
src.start(start, from, len); at = start + len;
|
|
});
|
|
const out = await ctx.startRendering();
|
|
const ch = out.getChannelData(0), pcm = new Int16Array(ch.length);
|
|
for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767;
|
|
return asDataUrl(wavBlob(pcm, MASH_SR));
|
|
}
|
|
|
|
export async function mashup(rows) {
|
|
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
|
|
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
|
|
if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; }
|
|
toast(`Mashing ${rows.length} tracks — it goes next.`, 4000);
|
|
try {
|
|
const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]);
|
|
if (ref) spec.refAudio = ref;
|
|
const job = makeJob(spec);
|
|
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob;
|
|
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP";
|
|
} catch (e) { toast(e.message, 5000); }
|
|
}
|