8f10353f0c
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
1415 lines
54 KiB
JavaScript
1415 lines
54 KiB
JavaScript
import Multitrack from "/vendor/multitrack.js";
|
||
import { fmtTime } from "./utils.js";
|
||
import {
|
||
STEM_NAMES, TRACK_NAMES, STEM_COLORS, PROGRESS_COLOR,
|
||
LOOP_DEFAULT_START_FRAC, LOOP_DEFAULT_END_FRAC, LANE_VOLUME_MAX,
|
||
} from "./constants.js";
|
||
import {
|
||
mixerEl, multitrackContainer, bpmChip, keyChip, stemsChip, timeEl,
|
||
titleEl, npThumb, rulerTime, wavesGrid, playBtn,
|
||
stopBtn, loopBtn, loopRegionEl,
|
||
multitrack, currentJobId, trackIndex, totalDuration, loopEnabled,
|
||
loopStart, loopEnd, trackAnalysers,
|
||
masterVolume, masterFader, mixerState,
|
||
audioEngine, setAudioEngine,
|
||
setMultitrack, setCurrentJobId, setTrackIndex, setTotalDuration,
|
||
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume,
|
||
waveScroll, selectedStems,
|
||
footerTitle, footerMeta, footerThumb,
|
||
setFooterWaveDrawFn,
|
||
} from "./state.js";
|
||
import { createAudioEngine, estimateDecodedBytes } from "./audioEngine.js";
|
||
import {
|
||
loadMixIntoState, resetMixerState, refreshMixerVisuals,
|
||
setLaneControlsEnabled, ensureMixerStateDefaults, applyMix,
|
||
renderRealMiniWave, renderMixerRow,
|
||
} from "./mixer.js";
|
||
import {
|
||
buildRuler, updatePlayheadMarker, updateLoopRegionVisual,
|
||
applyWaveZoom, buildPresenceRuler, updateFooterTimes,
|
||
updatePresencePlayhead, resetSpeed,
|
||
} from "./transport.js";
|
||
import { stopVuLoop } from "./audio.js";
|
||
import { destroySections } from "./sections.js";
|
||
|
||
// Feature flag for the Web Audio decode-and-mix engine (audioEngine.js).
|
||
// Playback runs off decoded AudioBuffers on a single AudioContext clock instead
|
||
// of N streaming <audio> elements — fixes Safari/WKWebView choppiness.
|
||
//
|
||
// Default ON everywhere. The prior Windows regression (waveform stuck, no audio)
|
||
// was caused by connection competition: both the multitrack and the engine
|
||
// fetched the same 6 stem WAVs concurrently, saturating the 6-connection
|
||
// HTTP/1.1 limit on WebView2/WKWebView and stalling multitrack's blob fetches
|
||
// so `canplay` never fired. Fixed by passing null URLs to the multitrack when
|
||
// the engine is active (wireUpAudio), giving the engine exclusive HTTP access.
|
||
// An explicit localStorage "stemdeck.audioEngine" = "0" forces the streaming
|
||
// path; "1" forces the engine (useful for debugging).
|
||
function audioEngineEnabled() {
|
||
try {
|
||
const pref = localStorage.getItem("stemdeck.audioEngine");
|
||
if (pref === "1") return true;
|
||
if (pref === "0") return false;
|
||
} catch (e) {
|
||
console.warn("[player] audioEngine flag read failed:", e);
|
||
}
|
||
return true; // default ON — smooth audio everywhere
|
||
}
|
||
|
||
// Above this estimated decoded-PCM size the engine is skipped and playback
|
||
// falls back to streaming, so a long full-stem track can't exhaust WKWebView's
|
||
// memory. ~1.2 GB ≈ 6 stems × ~10 min (or 4 stems × ~15 min) at 44.1 kHz/Float32.
|
||
const MAX_ENGINE_DECODED_BYTES = 1.2e9;
|
||
|
||
// Stem-selection filter: the import-page stem-choice toggles set
|
||
// selectedStems (state.js). Backend always processes all 6 -- we
|
||
// hide the rows for unselected stems in the studio dashboard so the
|
||
// user "only sees what they selected to extract".
|
||
const _STEM_ROW_SELECTORS = [
|
||
".stem-list span[data-stem]",
|
||
".presence-bars i[data-stem]",
|
||
".presence-labels span",
|
||
];
|
||
|
||
function applyStemSelectionFilter(presentNames) {
|
||
// Waveform rows: original hides if absent; STEM_NAMES rows always show, grayed if absent
|
||
for (const el of document.querySelectorAll(".stem-waveform-row[data-stem]")) {
|
||
const stem = el.dataset.stem;
|
||
if (stem === "original") {
|
||
el.classList.toggle("hidden", !presentNames.has(stem));
|
||
el.classList.remove("unavailable");
|
||
} else {
|
||
el.classList.remove("hidden");
|
||
el.classList.toggle("unavailable", !presentNames.has(stem));
|
||
}
|
||
}
|
||
const originalRow = presentNames.has("original") ? 1 : 0;
|
||
const visibleTrackCount = originalRow + STEM_NAMES.length;
|
||
const app = document.querySelector(".app");
|
||
app?.style.setProperty("--visible-track-count", String(visibleTrackCount));
|
||
app?.style.setProperty(
|
||
"--wave-widget-track-stack-h",
|
||
`${(visibleTrackCount * WAVEFORM_LANE_HEIGHT) + ((visibleTrackCount - 1) * WAVEFORM_SEPARATOR_HEIGHT)}px`,
|
||
);
|
||
for (const sel of _STEM_ROW_SELECTORS) {
|
||
for (const el of document.querySelectorAll(sel)) {
|
||
const stem = el.dataset.stem
|
||
|| el.classList[0]; // .presence-labels span has no data-stem, use class
|
||
el.classList.toggle("hidden", !presentNames.has(stem));
|
||
}
|
||
}
|
||
const visibleMixerNames = [];
|
||
if (presentNames.has("original")) visibleMixerNames.push("original");
|
||
for (const name of STEM_NAMES) {
|
||
if (presentNames.has(name)) visibleMixerNames.push(name);
|
||
}
|
||
const mixerCap = STEM_NAMES.length + (presentNames.has("original") ? 1 : 0);
|
||
for (const name of STEM_NAMES) {
|
||
if (visibleMixerNames.length >= mixerCap) break;
|
||
if (!visibleMixerNames.includes(name)) visibleMixerNames.push(name);
|
||
}
|
||
const visibleMixerSet = new Set(visibleMixerNames);
|
||
|
||
for (const row of document.querySelectorAll(".mixer-column .lane-header[data-stem]")) {
|
||
const stem = row.dataset.stem;
|
||
const available = presentNames.has(stem);
|
||
row.classList.toggle("hidden", !visibleMixerSet.has(stem));
|
||
row.classList.toggle("unavailable", !available);
|
||
row.setAttribute("aria-disabled", String(!available));
|
||
for (const el of row.querySelectorAll("button, .lane-knob, .lane-dl")) {
|
||
el.classList.toggle("disabled", !available);
|
||
if (available) {
|
||
el.removeAttribute("aria-disabled");
|
||
if (el.matches(".lane-knob")) el.setAttribute("tabindex", "0");
|
||
else el.removeAttribute("tabindex");
|
||
} else {
|
||
el.setAttribute("aria-disabled", "true");
|
||
el.setAttribute("tabindex", "-1");
|
||
}
|
||
if ("disabled" in el) el.disabled = !available;
|
||
}
|
||
}
|
||
for (const row of document.querySelectorAll(".energy-row[data-stem]")) {
|
||
const available = presentNames.has(row.dataset.stem);
|
||
row.classList.toggle("unavailable", !available);
|
||
row.classList.remove("hidden");
|
||
}
|
||
}
|
||
|
||
function clearStemSelectionFilter() {
|
||
const app = document.querySelector(".app");
|
||
app?.style.setProperty("--visible-track-count", String(TRACK_NAMES.length));
|
||
app?.style.setProperty(
|
||
"--wave-widget-track-stack-h",
|
||
`${(TRACK_NAMES.length * WAVEFORM_LANE_HEIGHT) + ((TRACK_NAMES.length - 1) * WAVEFORM_SEPARATOR_HEIGHT)}px`,
|
||
);
|
||
for (const sel of _STEM_ROW_SELECTORS) {
|
||
for (const el of document.querySelectorAll(sel)) {
|
||
el.classList.remove("hidden");
|
||
}
|
||
}
|
||
for (const el of document.querySelectorAll(".stem-waveform-row[data-stem]")) {
|
||
el.classList.remove("hidden");
|
||
el.classList.remove("unavailable");
|
||
}
|
||
for (const row of document.querySelectorAll(".mixer-column .lane-header[data-stem], .energy-row[data-stem]")) {
|
||
row.classList.remove("hidden");
|
||
row.classList.remove("unavailable");
|
||
row.removeAttribute("aria-disabled");
|
||
}
|
||
}
|
||
|
||
// Reset the analysis cards (key, scale, confidence ring, loudness)
|
||
// between songs so a re-import doesn't flash the previous song's
|
||
// numbers before the new ones arrive via SSE.
|
||
function resetAnalysisCards() {
|
||
const summaryKey = document.getElementById("summary-key");
|
||
const summaryBpm = document.getElementById("summary-bpm");
|
||
const summaryScale = document.getElementById("summary-scale");
|
||
const summaryConfidence = document.getElementById("summary-confidence");
|
||
const summaryConfidenceLabel = document.getElementById("summary-confidence-label");
|
||
const loudnessCard = document.getElementById("loudness-card");
|
||
if (summaryKey) summaryKey.textContent = "—";
|
||
if (summaryBpm) summaryBpm.innerHTML = "— <small>BPM</small>";
|
||
if (summaryScale) summaryScale.textContent = "";
|
||
if (summaryConfidence) {
|
||
summaryConfidence.textContent = "";
|
||
summaryConfidence.style.removeProperty("--confidence-pct");
|
||
summaryConfidence.classList.add("hidden");
|
||
}
|
||
if (summaryConfidenceLabel) summaryConfidenceLabel.classList.add("hidden");
|
||
if (loudnessCard) loudnessCard.classList.add("hidden");
|
||
}
|
||
|
||
function renderPlaceholderTracks() {
|
||
multitrackContainer.innerHTML = "";
|
||
for (const name of ["original", ...STEM_NAMES]) {
|
||
const ph = document.createElement("div");
|
||
ph.className = "lane-placeholder";
|
||
ph.dataset.stem = name;
|
||
ph.style.setProperty("--lane-color", STEM_COLORS[name] || "#a0a0a0");
|
||
multitrackContainer.appendChild(ph);
|
||
}
|
||
}
|
||
|
||
const OVERVIEW_WAVE_POINTS = 1500;
|
||
const STEM_VU_FPS = 30;
|
||
const WAVEFORM_LANE_HEIGHT = 70;
|
||
const WAVEFORM_SEPARATOR_HEIGHT = 2;
|
||
let visualRenderToken = 0;
|
||
let visualAudioContext = null;
|
||
let stemVuRafId = null;
|
||
let _footerWavePeaks = null;
|
||
let _lastFooterProgress = 0;
|
||
let _footerPlaceholderResizeObs = null;
|
||
const _FOOTER_BARS = 300;
|
||
|
||
function isAudioBufferLike(value) {
|
||
return value && typeof value.getChannelData === "function";
|
||
}
|
||
|
||
function clearOverviewWaveforms() {
|
||
document.querySelector(".stem-waveform-layer")?.remove();
|
||
}
|
||
|
||
function resetStemMeters() {
|
||
for (const meter of document.querySelectorAll(".mini-meter")) {
|
||
meter.style.setProperty("--vu-scale", "0");
|
||
meter.style.setProperty("--vu-peak-pct", "0");
|
||
meter.style.setProperty("--vu-peak-opacity", "0");
|
||
}
|
||
for (const laneVu of mixerEl.querySelectorAll(".lane-vu")) {
|
||
laneVu.style.setProperty("--vu-level", "0%");
|
||
laneVu.style.setProperty("--vu-peak", "0%");
|
||
}
|
||
}
|
||
|
||
function stopStemVuLoop() {
|
||
if (stemVuRafId) {
|
||
cancelAnimationFrame(stemVuRafId);
|
||
stemVuRafId = null;
|
||
}
|
||
resetStemMeters();
|
||
}
|
||
|
||
function ensureOverviewWaveformLayer() {
|
||
let layer = document.querySelector(".stem-waveform-layer");
|
||
if (!layer) {
|
||
layer = document.createElement("div");
|
||
layer.className = "stem-waveform-layer";
|
||
multitrackContainer.parentElement?.appendChild(layer);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
// Standard DAW-style waveform: track min and max raw sample values per
|
||
// pixel column. The signed peaks let us render the natural mirror-
|
||
// symmetric shape (top edge follows max, bottom follows min) and keeps
|
||
// transient detail that an RMS envelope would smooth away.
|
||
function bufferMinMaxPeaks(audioBuffer, count) {
|
||
const ch = audioBuffer.getChannelData(0);
|
||
const binSize = Math.max(1, Math.floor(ch.length / count));
|
||
const peaks = new Array(count);
|
||
for (let i = 0; i < count; i++) {
|
||
const start = i * binSize;
|
||
const end = i === count - 1 ? ch.length : Math.min(ch.length, start + binSize);
|
||
let mn = 0;
|
||
let mx = 0;
|
||
for (let j = start; j < end; j++) {
|
||
const v = ch[j];
|
||
if (v > mx) mx = v;
|
||
else if (v < mn) mn = v;
|
||
}
|
||
peaks[i] = [mn, mx];
|
||
}
|
||
return peaks;
|
||
}
|
||
|
||
// The overview lanes are drawn as discrete vertical bars (mirrored around the
|
||
// center line), matching WaveSurfer's barWidth:3 / barGap:2 / barRadius:2 look
|
||
// used on the streaming path -- so the engine path (this SVG, from peaks.json)
|
||
// is visually identical to the WaveSurfer canvas. We set the SVG viewBox width
|
||
// to the bar count and stretch it across the lane (preserveAspectRatio="none"),
|
||
// so one viewBox unit == one bar slot. Choosing barCount = laneWidth / 5 makes
|
||
// each slot 5px, and a 0.6-unit bar renders as ~3px with a ~2px gap.
|
||
const OVERVIEW_BAR_SLOT_PX = 5; // 3px bar + 2px gap, matches WaveSurfer defaults
|
||
const OVERVIEW_BAR_FRAC = 0.6; // bar width as a fraction of its slot (3/5)
|
||
|
||
// How many bars fit across the current lane width. Falls back to a sane count
|
||
// before layout settles. Returned bars are clamped so very narrow panels still
|
||
// show a readable waveform.
|
||
function overviewBarCount() {
|
||
const w = document.querySelector(".waves-column")?.clientWidth || 0;
|
||
const t = w > 0 ? Math.round(w / OVERVIEW_BAR_SLOT_PX) : 220;
|
||
return Math.max(40, t);
|
||
}
|
||
|
||
// Downsample signed [min,max] peaks to `barCount` mirrored bars and emit them as
|
||
// <rect>s in a viewBox whose width == barCount (1 unit per slot). `norm` is the
|
||
// shared cross-stem normalization so loudness relationships are preserved.
|
||
function barsWaveformSvg(peaks, norm, barCount) {
|
||
const n = peaks.length;
|
||
if (!n) return "";
|
||
const off = (1 - OVERVIEW_BAR_FRAC) / 2;
|
||
const rects = new Array(barCount);
|
||
for (let i = 0; i < barCount; i++) {
|
||
const start = Math.floor((i * n) / barCount);
|
||
const end = Math.max(start + 1, Math.floor(((i + 1) * n) / barCount));
|
||
let mn = 0;
|
||
let mx = 0;
|
||
for (let j = start; j < end && j < n; j++) {
|
||
if (peaks[j][0] < mn) mn = peaks[j][0];
|
||
if (peaks[j][1] > mx) mx = peaks[j][1];
|
||
}
|
||
const amp = Math.min(1, Math.max(-mn, mx) * norm);
|
||
// Min height keeps silent regions as a faint baseline dotted line (as the
|
||
// WaveSurfer bars did) instead of vanishing entirely.
|
||
const h = Math.max(0.7, amp * 44);
|
||
rects[i] =
|
||
`<rect x="${(i + off).toFixed(3)}" y="${(24 - h / 2).toFixed(2)}" `
|
||
+ `width="${OVERVIEW_BAR_FRAC}" height="${h.toFixed(2)}" rx="0.3"></rect>`;
|
||
}
|
||
return rects.join("");
|
||
}
|
||
|
||
// Mixer-column mini-wave keeps a per-stem normalized envelope (each
|
||
// thumbnail fills its own little box). Used by mixer.js indirectly via
|
||
// renderRealMiniWave, which has its own peak computation.
|
||
function bufferPeaks(audioBuffer, count) {
|
||
const peaks = bufferMinMaxPeaks(audioBuffer, count);
|
||
let max = 0;
|
||
for (const [mn, mx] of peaks) {
|
||
if (mx > max) max = mx;
|
||
if (-mn > max) max = -mn;
|
||
}
|
||
const norm = max > 0 ? 1 / max : 0;
|
||
return peaks.map(([mn, mx]) => Math.max(Math.min(1, mx * norm), -mn * norm));
|
||
}
|
||
|
||
function waveformPath(peaks) {
|
||
const top = peaks.map((amp, i) => {
|
||
const x = (i / (peaks.length - 1)) * 100;
|
||
const y = 24 - amp * 21;
|
||
return `${i === 0 ? "M" : "L"}${x.toFixed(3)} ${y.toFixed(3)}`;
|
||
});
|
||
const bottom = [...peaks].reverse().map((amp, i) => {
|
||
const x = ((peaks.length - 1 - i) / (peaks.length - 1)) * 100;
|
||
const y = 24 + amp * 21;
|
||
return `L${x.toFixed(3)} ${y.toFixed(3)}`;
|
||
});
|
||
return `${top.join(" ")} ${bottom.join(" ")} Z`;
|
||
}
|
||
|
||
function renderOverviewWaveformPath(stemName, peaks, norm, color, barCount) {
|
||
const layer = ensureOverviewWaveformLayer();
|
||
let row = layer.querySelector(`[data-stem="${stemName}"]`);
|
||
if (!row) {
|
||
row = document.createElement("div");
|
||
row.className = "stem-waveform-row";
|
||
row.dataset.stem = stemName;
|
||
layer.appendChild(row);
|
||
}
|
||
row.style.setProperty("--stem-color", color);
|
||
row.style.order = String(TRACK_NAMES.indexOf(stemName));
|
||
// A row is created for every mixer lane, including stems with no audio (e.g.
|
||
// a subset extraction). The rows are flex-distributed across the lane stack,
|
||
// so they only stay 1:1 with the mixer lanes when their count matches; an
|
||
// empty lane renders nothing but keeps that alignment. Without this, fewer
|
||
// waveform rows than lanes stretch to fill the stack and drift off their
|
||
// tracks.
|
||
if (!peaks?.length) {
|
||
row.innerHTML = "";
|
||
return;
|
||
}
|
||
const bars = barCount || overviewBarCount();
|
||
row.innerHTML = `
|
||
<svg class="stem-waveform-svg" viewBox="0 0 ${bars} 48" preserveAspectRatio="none" aria-hidden="true">
|
||
${barsWaveformSvg(peaks, norm, bars)}
|
||
</svg>
|
||
`;
|
||
}
|
||
|
||
// The lane set must mirror the mixer/multitrack lanes (orderedNames in
|
||
// wireUpAudio): "original" plus the stems when an original lane is present,
|
||
// otherwise just the stems. Rendering a row for every lane keeps the overlay
|
||
// aligned even when only a subset of stems was extracted.
|
||
function overviewLaneNames(stems) {
|
||
return stems.some((s) => s.name === "original") ? TRACK_NAMES : STEM_NAMES;
|
||
}
|
||
|
||
function renderAllOverviewWaveformsFromPeaks(stems, peaksData) {
|
||
const laneNames = overviewLaneNames(stems);
|
||
// Only the extracted/selected stems (plus original) get a waveform, even if
|
||
// peaks.json carries data for stems the user didn't keep (Demucs separates
|
||
// all 6 internally). Every lane still gets a ROW so the overlay stays aligned;
|
||
// non-selected lanes just render empty.
|
||
const present = new Set(stems.map((s) => s.name));
|
||
let globalMax = 0;
|
||
for (const name of laneNames) {
|
||
if (!present.has(name)) continue;
|
||
const pts = peaksData[name];
|
||
if (!pts?.length) continue;
|
||
for (const [mn, mx] of pts) {
|
||
if (mx > globalMax) globalMax = mx;
|
||
if (-mn > globalMax) globalMax = -mn;
|
||
}
|
||
}
|
||
const norm = globalMax > 0 ? 1 / globalMax : 0;
|
||
const bars = overviewBarCount();
|
||
for (const name of laneNames) {
|
||
const pts = present.has(name) ? peaksData[name] : null;
|
||
renderOverviewWaveformPath(name, pts, norm, STEM_COLORS[name] || "#a0a0a0", bars);
|
||
}
|
||
}
|
||
|
||
// Normalize all stems to a single shared max so the overview waveforms
|
||
// preserve real amplitude relationships (drums tall, piano short),
|
||
// matching what a DAW shows. Per-stem normalization made every lane
|
||
// fill its row regardless of how loud the stem actually was.
|
||
function renderAllOverviewWaveforms(stems, decodedMap) {
|
||
const laneNames = overviewLaneNames(stems);
|
||
const peaksByStem = new Map();
|
||
let globalMax = 0;
|
||
for (const name of laneNames) {
|
||
const buf = decodedMap.get(name);
|
||
if (!isAudioBufferLike(buf)) continue;
|
||
const peaks = bufferMinMaxPeaks(buf, OVERVIEW_WAVE_POINTS);
|
||
peaksByStem.set(name, peaks);
|
||
for (const [mn, mx] of peaks) {
|
||
if (mx > globalMax) globalMax = mx;
|
||
if (-mn > globalMax) globalMax = -mn;
|
||
}
|
||
}
|
||
const norm = globalMax > 0 ? 1 / globalMax : 0;
|
||
const bars = overviewBarCount();
|
||
for (const name of laneNames) {
|
||
renderOverviewWaveformPath(name, peaksByStem.get(name), norm, STEM_COLORS[name] || "#a0a0a0", bars);
|
||
}
|
||
}
|
||
|
||
function renderDecodedStemVisuals(stemName, audioBuffer, color) {
|
||
if (!isAudioBufferLike(audioBuffer)) return;
|
||
renderRealMiniWave(stemName, audioBuffer, color);
|
||
}
|
||
|
||
// Set the song-level "Stem Energy" panel from each stem's overall RMS.
|
||
// Without this baseline the bars sit at 0% until the user hits play
|
||
// (because audio.js only writes per-frame during active playback) and
|
||
// look like static placeholders. Normalizing all stems to the loudest
|
||
// one's RMS gives a meaningful relative balance ("drums dominate, piano
|
||
// quiet"), which is what a DAW-style energy panel is supposed to show.
|
||
// Once playback starts, audio.js's per-frame writes override these
|
||
// baseline values for real-time pulsing.
|
||
function renderStemEnergyBaseline(stems, decodedMap) {
|
||
const rmsByStem = new Map();
|
||
let maxRms = 0;
|
||
for (const stem of stems) {
|
||
const buf = decodedMap.get(stem.name);
|
||
if (!isAudioBufferLike(buf)) continue;
|
||
const ch = buf.getChannelData(0);
|
||
if (!ch?.length) continue;
|
||
let sum = 0;
|
||
for (let i = 0; i < ch.length; i++) sum += ch[i] * ch[i];
|
||
const rms = Math.sqrt(sum / ch.length);
|
||
rmsByStem.set(stem.name, rms);
|
||
if (rms > maxRms) maxRms = rms;
|
||
}
|
||
if (maxRms <= 0) return;
|
||
for (const [name, rms] of rmsByStem) {
|
||
const pct = Math.round((rms / maxRms) * 100);
|
||
const row = document.querySelector(`.energy-row[data-stem="${name}"]`);
|
||
if (!row) continue;
|
||
const bar = row.querySelector("b");
|
||
const txt = row.querySelector("em");
|
||
if (bar) bar.style.setProperty("--v", `${pct}%`);
|
||
if (txt) txt.textContent = `${pct}%`;
|
||
}
|
||
}
|
||
|
||
function buildStemVuEnvelope(audioBuffer) {
|
||
if (!isAudioBufferLike(audioBuffer)) return [];
|
||
const ch = audioBuffer.getChannelData(0);
|
||
const sampleRate = audioBuffer.sampleRate || 44100;
|
||
const duration = audioBuffer.duration || (ch.length / sampleRate);
|
||
const frameCount = Math.max(1, Math.ceil(duration * STEM_VU_FPS));
|
||
const hop = Math.max(1, Math.floor(sampleRate / STEM_VU_FPS));
|
||
const win = Math.max(1, Math.floor(sampleRate * 0.045));
|
||
const env = new Float32Array(frameCount);
|
||
let max = 0;
|
||
for (let i = 0; i < frameCount; i++) {
|
||
const center = Math.min(ch.length - 1, i * hop);
|
||
const start = Math.max(0, center - Math.floor(win / 2));
|
||
const end = Math.min(ch.length, start + win);
|
||
let sum = 0;
|
||
let peak = 0;
|
||
for (let j = start; j < end; j++) {
|
||
const v = Math.abs(ch[j]);
|
||
sum += v * v;
|
||
if (v > peak) peak = v;
|
||
}
|
||
const rms = Math.sqrt(sum / Math.max(1, end - start));
|
||
const level = rms * 0.78 + peak * 0.22;
|
||
env[i] = level;
|
||
if (level > max) max = level;
|
||
}
|
||
if (max <= 0) return env;
|
||
for (let i = 0; i < env.length; i++) {
|
||
env[i] = Math.min(1, Math.sqrt(env[i] / max));
|
||
}
|
||
return env;
|
||
}
|
||
|
||
function stemVuGain(stemName) {
|
||
const state = mixerState[stemName];
|
||
if (!state) return 0;
|
||
const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed);
|
||
if (state.muted || (anySolo && !state.soloed)) return 0;
|
||
return Math.max(0, state.volume);
|
||
}
|
||
|
||
function startStemVuLoop(stems, decodedMap, token) {
|
||
stopStemVuLoop();
|
||
const meters = stems.map((stem) => ({
|
||
name: stem.name,
|
||
env: buildStemVuEnvelope(decodedMap.get(stem.name)),
|
||
miniMeterEl: document.querySelector(`.stem-list [data-stem="${stem.name}"] .mini-meter`),
|
||
vuEl: mixerEl.querySelector(`.lane-vu[data-stem="${stem.name}"]`),
|
||
peak: 0,
|
||
peakHold: 0,
|
||
holdFrames: 0,
|
||
lastPeakPct: -1,
|
||
lastHoldPct: -1,
|
||
lastLevelPct: -1,
|
||
})).filter((m) => m.env.length && (m.miniMeterEl || m.vuEl));
|
||
|
||
if (!meters.length) return;
|
||
const tick = () => {
|
||
if (token !== visualRenderToken || !multitrack) return;
|
||
// When the Web Audio engine owns playback, the multitrack is silent — read
|
||
// the clock/transport state from the engine so the meters track real audio.
|
||
const src = audioEngine ?? multitrack;
|
||
const playing = src.isPlaying?.() ?? false;
|
||
const time = src.getCurrentTime?.() ?? 0;
|
||
for (const m of meters) {
|
||
const idx = Math.max(0, Math.min(m.env.length - 1, Math.floor(time * STEM_VU_FPS)));
|
||
const gain = stemVuGain(m.name);
|
||
const input = playing && gain > 0 ? Math.min(1, m.env[idx] * gain) : 0;
|
||
if (gain <= 0) {
|
||
m.peak = 0;
|
||
m.peakHold = 0;
|
||
m.holdFrames = 0;
|
||
}
|
||
const nextPeak = input > m.peak ? input : Math.max(0, m.peak - 0.018);
|
||
m.peak = nextPeak;
|
||
|
||
if (input > m.peakHold) {
|
||
m.peakHold = input;
|
||
m.holdFrames = 28;
|
||
} else if (m.holdFrames > 0) {
|
||
m.holdFrames -= 1;
|
||
} else {
|
||
m.peakHold = Math.max(0, m.peakHold - 0.025);
|
||
}
|
||
|
||
const lvlPct = Math.round(input * 100);
|
||
const peakPct = Math.round(nextPeak * 100);
|
||
const holdPct = Math.round(m.peakHold * 100);
|
||
|
||
if (m.miniMeterEl) {
|
||
if (peakPct !== m.lastPeakPct) {
|
||
m.miniMeterEl.style.setProperty("--vu-scale", nextPeak.toFixed(3));
|
||
}
|
||
if (holdPct !== m.lastHoldPct) {
|
||
m.miniMeterEl.style.setProperty("--vu-peak-pct", String(holdPct));
|
||
m.miniMeterEl.style.setProperty("--vu-peak-opacity", m.peakHold > 0.04 ? "1" : "0");
|
||
}
|
||
}
|
||
if (m.vuEl) {
|
||
if (lvlPct !== m.lastLevelPct) m.vuEl.style.setProperty("--vu-level", `${lvlPct}%`);
|
||
if (holdPct !== m.lastHoldPct) m.vuEl.style.setProperty("--vu-peak", `${holdPct}%`);
|
||
}
|
||
m.lastLevelPct = lvlPct;
|
||
m.lastPeakPct = peakPct;
|
||
m.lastHoldPct = holdPct;
|
||
}
|
||
stemVuRafId = requestAnimationFrame(tick);
|
||
};
|
||
stemVuRafId = requestAnimationFrame(tick);
|
||
}
|
||
|
||
export function destroyPlayer() {
|
||
document.querySelector(".app")?.classList.remove("is-import");
|
||
document.querySelector(".app")?.classList.remove("engine-waveforms");
|
||
document.querySelector(".app")?.classList.add("no-track");
|
||
destroySections();
|
||
stopVuLoop();
|
||
stopStemVuLoop();
|
||
resetSpeed();
|
||
if (audioEngine) {
|
||
audioEngine.destroy();
|
||
setAudioEngine(null);
|
||
}
|
||
if (multitrack) {
|
||
multitrack.destroy();
|
||
setMultitrack(null);
|
||
}
|
||
if (visualAudioContext) {
|
||
visualAudioContext.close().catch(() => {});
|
||
visualAudioContext = null;
|
||
}
|
||
renderPlaceholderTracks();
|
||
clearOverviewWaveforms();
|
||
for (const row of mixerEl.querySelectorAll(".lane-header")) {
|
||
const dl = row.querySelector(".lane-dl");
|
||
if (dl) {
|
||
dl.href = "#";
|
||
dl.removeAttribute("download");
|
||
}
|
||
}
|
||
resetMixerState();
|
||
refreshMixerVisuals();
|
||
setLaneControlsEnabled(false);
|
||
// Reset static rows, then keep the pre-import shell to extractable stems
|
||
// only. wireUpAudio will re-apply the exact returned-track set.
|
||
clearStemSelectionFilter();
|
||
applyStemSelectionFilter(new Set(STEM_NAMES));
|
||
npThumb.classList.remove("loaded");
|
||
npThumb.removeAttribute("src");
|
||
|
||
rulerTime.innerHTML = '<div class="playhead-marker" aria-hidden="true"><svg viewBox="0 0 10 10" width="10" height="10"><polygon points="0,0 10,0 5,8" fill="#e54e4e"></polygon></svg></div>';
|
||
wavesGrid.innerHTML = "";
|
||
|
||
titleEl.textContent = "";
|
||
bpmChip.textContent = "\u2014 BPM";
|
||
keyChip.textContent = "\u2014 \u2014";
|
||
stemsChip.textContent = "\u2014 Stems";
|
||
timeEl.textContent = "00:00 / 00:00";
|
||
resetAnalysisCards();
|
||
|
||
trackAnalysers.length = 0;
|
||
for (const row of document.querySelectorAll(".energy-row")) {
|
||
const bar = row.querySelector("b");
|
||
const txt = row.querySelector("em");
|
||
if (bar) bar.style.setProperty("--v", "0%");
|
||
if (txt) txt.textContent = "0%";
|
||
}
|
||
setTotalDuration(0);
|
||
setLoopEnabled(false);
|
||
setLoopStart(0);
|
||
setLoopEnd(0);
|
||
setMasterVolume(0.5);
|
||
setTrackIndex({});
|
||
applyWaveZoom();
|
||
buildPresenceRuler(0);
|
||
updateFooterTimes(0);
|
||
updatePresencePlayhead(0);
|
||
if (waveScroll) waveScroll.scrollLeft = 0;
|
||
loopBtn.classList.remove("active");
|
||
playBtn.classList.remove("playing");
|
||
stopBtn.classList.remove("stopped");
|
||
loopRegionEl.classList.add("hidden");
|
||
_footerWavePeaks = null;
|
||
_lastFooterProgress = 0;
|
||
_footerPlaceholderResizeObs?.disconnect();
|
||
_footerPlaceholderResizeObs = null;
|
||
_footerWaveResizeObs?.disconnect();
|
||
setFooterWaveDrawFn(null);
|
||
const cv = document.getElementById("footer-waveform");
|
||
if (cv) { const c = cv.getContext("2d"); c?.clearRect(0, 0, cv.width, cv.height); }
|
||
updateFooterTrack({});
|
||
}
|
||
|
||
export function renderEmptyShell() {
|
||
document.querySelector(".app")?.classList.remove("is-import");
|
||
document.querySelector(".app")?.classList.add("no-track");
|
||
stopStemVuLoop();
|
||
ensureMixerStateDefaults();
|
||
mixerEl.innerHTML = "";
|
||
for (const name of ["original", ...STEM_NAMES]) {
|
||
const { row } = renderMixerRow({ name, url: "#" });
|
||
mixerEl.appendChild(row);
|
||
}
|
||
requestAnimationFrame(() => _applyLaneHeight(1 + STEM_NAMES.length));
|
||
applyStemSelectionFilter(new Set(STEM_NAMES));
|
||
titleEl.textContent = "Ready to import a track";
|
||
bpmChip.textContent = "\u2014 BPM";
|
||
keyChip.textContent = "\u2014 \u2014";
|
||
stemsChip.textContent = "\u2014 Stems";
|
||
timeEl.textContent = "00:00 / 00:00";
|
||
resetAnalysisCards();
|
||
renderPlaceholderTracks();
|
||
clearOverviewWaveforms();
|
||
setLaneControlsEnabled(false);
|
||
}
|
||
|
||
function renderAllMiniWaves(mt, stems) {
|
||
const wsArr = mt.wavesurfers || mt._wavesurfers;
|
||
if (!wsArr?.length) return;
|
||
stems.forEach((stem) => {
|
||
const i = trackIndex[stem.name];
|
||
if (i === undefined) return;
|
||
const ws = wsArr[i];
|
||
if (!ws) return;
|
||
const color = STEM_COLORS[stem.name] || "#a0a0a0";
|
||
const tryRender = () => {
|
||
const buf = ws.getDecodedData?.();
|
||
if (isAudioBufferLike(buf)) {
|
||
renderDecodedStemVisuals(stem.name, buf, color);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
if (!tryRender()) ws.once?.("decode", tryRender);
|
||
});
|
||
}
|
||
|
||
let _loadingShownAt = 0;
|
||
const _LOADING_MIN_MS = 900;
|
||
let _currentStems = [];
|
||
let _mixUrl = null;
|
||
let _currentTitle = "";
|
||
// Whether the current job has a preserved video track (mp4 upload). Gates the
|
||
// "Export Mix (with video)" item (CSS .has-video) and downloadCurrentVideo().
|
||
let _currentHasVideo = false;
|
||
|
||
export function setWaveformLoading(loading, phrase) {
|
||
const el = document.getElementById("waveLoadingOverlay");
|
||
if (!el) return;
|
||
if (loading) {
|
||
_loadingShownAt = performance.now();
|
||
const phraseEl = document.getElementById("waveLoadingPhrase");
|
||
if (phraseEl && phrase !== undefined) phraseEl.textContent = phrase;
|
||
else if (phraseEl && !phraseEl.textContent) phraseEl.textContent = "Still loading waveform…";
|
||
el.classList.remove("hidden");
|
||
} else {
|
||
const elapsed = performance.now() - _loadingShownAt;
|
||
const delay = Math.max(0, _LOADING_MIN_MS - elapsed);
|
||
window.setTimeout(() => {
|
||
el.classList.add("hidden");
|
||
el.classList.remove("stalled");
|
||
const phraseEl = document.getElementById("waveLoadingPhrase");
|
||
if (phraseEl) phraseEl.textContent = "";
|
||
}, delay);
|
||
}
|
||
}
|
||
|
||
export function buildStripStems() {
|
||
const container = document.getElementById("appbarStripStems");
|
||
if (!container) return;
|
||
container.innerHTML = "";
|
||
for (const name of STEM_NAMES) {
|
||
const color = STEM_COLORS[name];
|
||
const active = selectedStems.has(name);
|
||
const sq = document.createElement("div");
|
||
sq.className = "strip-sq strip-sq-stem" + (active ? "" : " inactive");
|
||
sq.dataset.stem = name;
|
||
if (active) sq.style.cssText = `background:${color}1a;border-color:${color}44;color:${color}`;
|
||
const srcSvg = document.querySelector(`.stem-choice[data-stem="${name}"] svg`);
|
||
if (srcSvg) sq.appendChild(srcSvg.cloneNode(true));
|
||
container.appendChild(sq);
|
||
}
|
||
}
|
||
|
||
function _applyLaneHeight(count) {
|
||
const wavePanel = document.querySelector(".daw-wave-panel");
|
||
const panelH = wavePanel?.clientHeight ?? 0;
|
||
const laneH = panelH > 0 && count > 0
|
||
? Math.max(WAVEFORM_LANE_HEIGHT, Math.floor(panelH / count))
|
||
: WAVEFORM_LANE_HEIGHT;
|
||
const appEl = document.querySelector(".app");
|
||
appEl?.style.setProperty("--lane-h", `${laneH + 2}px`);
|
||
appEl?.style.setProperty(
|
||
"--wave-widget-track-stack-h",
|
||
`${count * laneH + (count - 1) * WAVEFORM_SEPARATOR_HEIGHT}px`,
|
||
);
|
||
return laneH;
|
||
}
|
||
|
||
export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, title = "", peaksPromise = null, hasVideo = false) {
|
||
const app = document.querySelector(".app");
|
||
app?.classList.remove("is-import");
|
||
app?.classList.remove("no-track");
|
||
setWaveformLoading(true);
|
||
stopVuLoop();
|
||
stopStemVuLoop();
|
||
if (multitrack) {
|
||
multitrack.destroy();
|
||
setMultitrack(null);
|
||
}
|
||
playBtn.classList.remove("playing");
|
||
stopBtn.classList.remove("stopped");
|
||
visualRenderToken += 1;
|
||
const token = visualRenderToken;
|
||
window.setTimeout(() => {
|
||
const el = document.getElementById("waveLoadingOverlay");
|
||
if (token === visualRenderToken && el && !el.classList.contains("hidden")) {
|
||
el.classList.add("stalled");
|
||
}
|
||
}, 20000);
|
||
window.setTimeout(() => {
|
||
if (token === visualRenderToken) setWaveformLoading(false);
|
||
}, 60000);
|
||
setCurrentJobId(jobId);
|
||
setTotalDuration(duration || 0);
|
||
refreshMixerVisuals();
|
||
const mixReady = loadMixIntoState(jobId, stems.map((s) => s.name))
|
||
.then(() => { if (currentJobId === jobId) refreshMixerVisuals(); })
|
||
.catch((e) => { console.warn("[player] mix state load failed:", e); });
|
||
setLaneControlsEnabled(true);
|
||
setLoopEnabled(false);
|
||
setLoopStart(0);
|
||
setLoopEnd(0);
|
||
loopBtn.classList.remove("active");
|
||
loopRegionEl.classList.add("hidden");
|
||
// Refresh loop UI so the exact-loop inputs enable + reset to 00:00.000 now
|
||
// that the track duration is known.
|
||
updateLoopRegionVisual();
|
||
|
||
// User-selected stems only. Backend produced all 6, but the import-
|
||
// page toggles tell us which ones the user actually wanted to see.
|
||
// Filter early so multitrack, decoded-visuals, energy baseline, and
|
||
// mini-waves all operate on the trimmed set. The synthetic "original"
|
||
// track always passes the filter -- the user wants the full song
|
||
// available alongside the isolated stems for A/B comparison. (When
|
||
// the user selected all 6 stems, the backend doesn't produce
|
||
// original.wav, so it's simply not in `stems` and the mixer/sidebar
|
||
// rows for it stay hidden.)
|
||
stems = stems.filter((s) => s.name === "original" || selectedStems.has(s.name));
|
||
_currentStems = stems;
|
||
_mixUrl = mixUrl || null;
|
||
_currentTitle = title || "";
|
||
_currentHasVideo = !!hasVideo;
|
||
document.getElementById("footer-export-wrap")?.classList.toggle("has-video", !!hasVideo);
|
||
applyStemSelectionFilter(new Set(stems.map((s) => s.name)));
|
||
updateFooterTrack({ thumbnail, stemCount: stems.filter((s) => s.name !== "original").length });
|
||
|
||
// Reset footer waveform state — will be re-populated below after peaks fetch.
|
||
_footerWavePeaks = null;
|
||
setFooterWaveDrawFn(null);
|
||
const waveformUrl = _mixUrl
|
||
|| stems.find((s) => s.name === "original")?.url
|
||
|| stems[0]?.url;
|
||
|
||
// Use the pre-started peaks promise from catalog.js (started in parallel with
|
||
// the job-data fetch) so peaks.json is resolved before Multitrack.create fires
|
||
// its stem WAV fetches. This keeps peaks.json out of Safari's 6-connection-per-
|
||
// origin window, preventing stem WAV queuing that causes buffer underruns.
|
||
let precomputedPeaks = {};
|
||
const _footerStemName = stems.find((s) => s.name === "original") ? "original" : stems[0]?.name;
|
||
const _peaksPromise = peaksPromise ?? (jobId
|
||
? (() => {
|
||
const ac = new AbortController();
|
||
const timer = setTimeout(() => ac.abort(), 3000);
|
||
return fetch(`/api/jobs/${jobId}/stems/peaks.json`, { signal: ac.signal })
|
||
.then((r) => (r.ok ? r.json() : {}))
|
||
.catch(() => ({}))
|
||
.finally(() => clearTimeout(timer));
|
||
})()
|
||
: Promise.resolve({}));
|
||
|
||
for (const stem of stems) {
|
||
const row = mixerEl.querySelector(`.lane-header[data-stem="${stem.name}"]`);
|
||
if (!row) continue;
|
||
const dl = row.querySelector(".lane-dl");
|
||
if (dl) {
|
||
dl.href = stem.url;
|
||
dl.download = `${stem.name}.wav`;
|
||
}
|
||
}
|
||
|
||
stemsChip.textContent = `${stems.length} Stems`;
|
||
|
||
if (thumbnail) {
|
||
npThumb.onload = () => npThumb.classList.add("loaded");
|
||
npThumb.onerror = () => npThumb.classList.remove("loaded");
|
||
npThumb.src = thumbnail;
|
||
}
|
||
|
||
clearOverviewWaveforms();
|
||
|
||
// "original" is prepended at row 0 only when it actually has a URL so it
|
||
// appears at the top. Omitting it when absent avoids a phantom 70px gap.
|
||
// STEM_NAMES follow at the next consecutive rows so mixer lanes stay aligned.
|
||
const stemsByName = Object.fromEntries(stems.map((s) => [s.name, s]));
|
||
const orderedNames = [...(stemsByName["original"] ? ["original"] : []), ...STEM_NAMES];
|
||
setTrackIndex(Object.fromEntries(orderedNames.map((name, i) => [name, i])));
|
||
multitrackContainer.innerHTML = "";
|
||
|
||
// Decide up front whether the Web Audio engine will own playback. When it
|
||
// does, the multitrack is mounted for visuals only and must NOT load the stem
|
||
// WAVs — otherwise its 6 blob fetches compete with the engine's 6 decodes for
|
||
// the 6-connection HTTP/1.1 limit and `canplay` stalls (the WebView2/WKWebView
|
||
// freeze). Null URLs make the multitrack's <audio> elements ready instantly.
|
||
const engineStemCount = stems.filter((s) => s.url).length;
|
||
const engineTooLarge =
|
||
estimateDecodedBytes(totalDuration, engineStemCount) > MAX_ENGINE_DECODED_BYTES;
|
||
const useEngine = audioEngineEnabled() && !engineTooLarge;
|
||
// The null-URL multitrack (engine path) has no WaveSurfer canvas, so reveal the
|
||
// SVG overview layer as the visible waveform (CSS hides it on the streaming path).
|
||
document.querySelector(".app")?.classList.toggle("engine-waveforms", useEngine);
|
||
if (engineTooLarge) {
|
||
console.warn(
|
||
`[player] track too large for Web Audio engine `
|
||
+ `(~${Math.round(estimateDecodedBytes(totalDuration, engineStemCount) / 1e6)} MB `
|
||
+ `for ${engineStemCount} stems × ${Math.round(totalDuration)}s); using streaming path`,
|
||
);
|
||
}
|
||
|
||
const laneH = _applyLaneHeight(orderedNames.length);
|
||
|
||
const mt = Multitrack.create(
|
||
orderedNames.map((name, i) => ({
|
||
id: i,
|
||
url: useEngine ? null : (stemsByName[name]?.url ?? null),
|
||
draggable: false,
|
||
startPosition: 0,
|
||
volume: stemsByName[name] ? 1 : 0,
|
||
options: {
|
||
waveColor: STEM_COLORS[name] || "#a0a0a0",
|
||
progressColor: PROGRESS_COLOR,
|
||
height: laneH,
|
||
barWidth: 3,
|
||
barGap: 2,
|
||
barRadius: 2,
|
||
cursorWidth: 0,
|
||
},
|
||
})),
|
||
{
|
||
container: multitrackContainer,
|
||
// 0 = fit waveforms to the container width. Any positive value
|
||
// makes the bundle's internal div wider than the visible area
|
||
// (so it scrolls horizontally), while our ruler ticks, playhead
|
||
// marker, and loop-region all render relative to the visible
|
||
// waves-column width — they go out of sync the moment the inner
|
||
// div scrolls. Fitting to view keeps the three perfectly aligned.
|
||
minPxPerSec: 0,
|
||
rightButtonDrag: false,
|
||
cursorWidth: 0,
|
||
cursorColor: "#e54e4e",
|
||
trackBackground: "transparent",
|
||
trackBorderColor: "rgba(148, 163, 184, 0.08)",
|
||
},
|
||
);
|
||
setMultitrack(mt);
|
||
|
||
// Apply peaks to visuals once the fetch resolves. Runs after Multitrack.create
|
||
// so AudioContext is already initialised before this callback fires.
|
||
_peaksPromise.then((peaks) => {
|
||
if (token !== visualRenderToken) return;
|
||
precomputedPeaks = peaks;
|
||
const footerPeaks = peaks[_footerStemName] || peaks[Object.keys(peaks)[0]];
|
||
if (footerPeaks?.length) {
|
||
const step = Math.ceil(footerPeaks.length / _FOOTER_BARS);
|
||
_footerWavePeaks = footerPeaks.filter((_, i) => i % step === 0).slice(0, _FOOTER_BARS);
|
||
setFooterWaveDrawFn(_drawFooterWave);
|
||
_drawFooterWave(0);
|
||
} else if (waveformUrl) {
|
||
initFooterWaveform(waveformUrl);
|
||
}
|
||
// Render the overview as soon as peaks arrive — independent of the audio
|
||
// `canplay` event, so the waveform appears even if the engine owns playback
|
||
// (null-URL multitrack) or canplay is delayed. Idempotent: the canplay
|
||
// handler also renders peaks, but both clearOverviewWaveforms() first.
|
||
if (Object.keys(peaks).length) {
|
||
clearOverviewWaveforms();
|
||
renderAllOverviewWaveformsFromPeaks(stems, peaks);
|
||
}
|
||
});
|
||
|
||
// Stop button glows iff transport is paused AND at the "start" (0,
|
||
// or loopStart if loop is on). Centralised here so manual seeks via
|
||
// the ruler also update the visual without extra plumbing.
|
||
const STOP_TOLERANCE_SEC = 0.15;
|
||
const updateStopVisual = () => {
|
||
const src = audioEngine ?? mt;
|
||
const t = src.getCurrentTime?.() ?? 0;
|
||
const startPos = loopEnabled ? loopStart : 0;
|
||
const atStart = Math.abs(t - startPos) < STOP_TOLERANCE_SEC;
|
||
const stopped = !src.isPlaying() && atStart;
|
||
stopBtn.classList.toggle("stopped", stopped);
|
||
};
|
||
|
||
mt.once("canplay", () => {
|
||
setWaveformLoading(false);
|
||
const ctx = mt.audioContext;
|
||
console.debug(
|
||
`[player] canplay — ${stems.length} stems, ctx=${ctx?.state}, audios:`,
|
||
mt.audios?.map((a, i) => `${orderedNames[i]}:${a?.constructor?.name}`),
|
||
);
|
||
// Log load errors only for stems that actually have a source URL
|
||
mt.audios?.forEach((a, i) => {
|
||
if (a instanceof HTMLMediaElement && stemsByName[orderedNames[i]]?.url) {
|
||
a.addEventListener("error", () =>
|
||
console.error(`[player] audio error stem[${i}] ${orderedNames[i]}:`, a.error?.message, a.error?.code),
|
||
{ once: true });
|
||
}
|
||
});
|
||
if (!totalDuration) setTotalDuration(mt.getDuration() || 0);
|
||
timeEl.textContent = `00:00 / ${fmtTime(totalDuration)}`;
|
||
buildRuler(totalDuration);
|
||
buildPresenceRuler(totalDuration);
|
||
updateFooterTimes(0);
|
||
updatePresencePlayhead(0);
|
||
setMasterVolume(masterFader ? parseFloat(masterFader.value) : masterVolume);
|
||
mixReady.then(() => { if (currentJobId === jobId && multitrack === mt) applyMix(); });
|
||
setLoopStart(totalDuration * LOOP_DEFAULT_START_FRAC);
|
||
setLoopEnd(totalDuration * LOOP_DEFAULT_END_FRAC);
|
||
// When the engine owns playback the multitrack has null URLs and no decoded
|
||
// audio, so mini-waves are driven from the engine's buffers instead (below).
|
||
if (!useEngine) renderAllMiniWaves(mt, stems);
|
||
applyWaveZoom();
|
||
if (Object.keys(precomputedPeaks).length) {
|
||
clearOverviewWaveforms();
|
||
renderAllOverviewWaveformsFromPeaks(stems, precomputedPeaks);
|
||
}
|
||
|
||
// CRITICAL: the Multitrack class itself does NOT emit play / pause /
|
||
// timeupdate / seeking — those fire on the individual wavesurfer
|
||
// instances. We pick wavesurfers[0] as the master clock since all
|
||
// stems are kept in sync by the bundle's startSync() loop.
|
||
const wsArr = mt.wavesurfers || mt._wavesurfers;
|
||
|
||
// Streaming path only (engine off): render overview/energy/VU from
|
||
// wavesurfer's decoded buffers. When the engine is on, the multitrack has
|
||
// null URLs (no decode), so these are driven from the engine's buffers in
|
||
// the eng.ready handler below instead.
|
||
if (!useEngine && wsArr?.length) {
|
||
const decoded = new Map();
|
||
const waits = stems.map((stem) => {
|
||
const idx = orderedNames.indexOf(stem.name);
|
||
const wsi = idx >= 0 ? wsArr[idx] : null;
|
||
if (!wsi) return Promise.resolve();
|
||
const buf = wsi.getDecodedData?.();
|
||
if (isAudioBufferLike(buf)) { decoded.set(stem.name, buf); return Promise.resolve(); }
|
||
return new Promise((resolve) => {
|
||
wsi.once?.("decode", () => { const buf = wsi.getDecodedData?.(); if (isAudioBufferLike(buf)) decoded.set(stem.name, buf); resolve(); });
|
||
window.setTimeout(resolve, 15000);
|
||
});
|
||
});
|
||
Promise.all(waits).then(() => {
|
||
if (token !== visualRenderToken) return;
|
||
if (!Object.keys(precomputedPeaks).length) {
|
||
clearOverviewWaveforms();
|
||
renderAllOverviewWaveforms(stems, decoded);
|
||
}
|
||
renderStemEnergyBaseline(stems, decoded);
|
||
startStemVuLoop(stems, decoded, token);
|
||
});
|
||
}
|
||
|
||
const ws = wsArr?.[0];
|
||
if (!ws) return;
|
||
|
||
let loopWrapLogged = false;
|
||
ws.on("timeupdate", (t) => {
|
||
timeEl.textContent = `${fmtTime(t)} / ${fmtTime(totalDuration)}`;
|
||
updatePlayheadMarker(t);
|
||
updateFooterTimes(t);
|
||
updatePresencePlayhead(t);
|
||
updateStopVisual();
|
||
if (loopEnabled && totalDuration > 0 && t >= loopEnd) {
|
||
if (!loopWrapLogged) {
|
||
loopWrapLogged = true;
|
||
}
|
||
mt.setTime(loopStart);
|
||
}
|
||
});
|
||
ws.on("play", () => {
|
||
playBtn.classList.add("playing");
|
||
stopBtn.classList.remove("stopped");
|
||
applyMix();
|
||
});
|
||
ws.on("pause", () => {
|
||
playBtn.classList.remove("playing");
|
||
updateStopVisual();
|
||
});
|
||
ws.on("seeking", updateStopVisual);
|
||
|
||
// ── Web Audio engine (flag-gated) ──────────────────────────────────────
|
||
// When enabled, decode the active stems and play them off a single
|
||
// AudioContext clock instead of the streaming <audio> elements above. The
|
||
// multitrack stays mounted for visuals only (it never plays), so the ws
|
||
// play/pause/timeupdate handlers wired above simply don't fire — the engine
|
||
// drives the identical UI updates via onTime/onEnded. transport.js, mixer
|
||
// applyMix, the VU loop, and updateStopVisual all route through
|
||
// `audioEngine ?? multitrack`, so flag-off is byte-identical to before.
|
||
if (useEngine) {
|
||
// Mirror the streaming "timeupdate" handler body so playhead/footer/
|
||
// presence/stop-visual update the same way regardless of which clock runs.
|
||
const driveTransportUi = (t) => {
|
||
timeEl.textContent = `${fmtTime(t)} / ${fmtTime(totalDuration)}`;
|
||
updatePlayheadMarker(t);
|
||
updateFooterTimes(t);
|
||
updatePresencePlayhead(t);
|
||
updateStopVisual();
|
||
};
|
||
if (audioEngine) { audioEngine.destroy(); setAudioEngine(null); }
|
||
const eng = createAudioEngine(stems, {
|
||
onTime: driveTransportUi,
|
||
onEnded: () => { playBtn.classList.remove("playing"); updateStopVisual(); },
|
||
});
|
||
setAudioEngine(eng);
|
||
eng.ready.then((ok) => {
|
||
// Bail if the user switched tracks while we were decoding.
|
||
if (token !== visualRenderToken || multitrack !== mt) {
|
||
eng.destroy();
|
||
if (audioEngine === eng) setAudioEngine(null);
|
||
return;
|
||
}
|
||
if (!ok) {
|
||
// No decodable stems — fall back to the streaming path transparently.
|
||
console.warn("[player] audio engine had no decodable stems; using streaming path");
|
||
eng.destroy();
|
||
setAudioEngine(null);
|
||
return;
|
||
}
|
||
eng.setLoop(loopEnabled, loopStart, loopEnd);
|
||
applyMix(); // push per-stem gains (incl. >1.0 boost) into the engine
|
||
// Drive the decode-dependent visuals from the engine's own decoded
|
||
// buffers (the null-URL multitrack has none): overview waveforms (when
|
||
// no precomputed peaks), energy bars, VU envelopes, and lane mini-waves.
|
||
const decoded = eng.getBuffers();
|
||
if (!Object.keys(precomputedPeaks).length) {
|
||
clearOverviewWaveforms();
|
||
renderAllOverviewWaveforms(stems, decoded);
|
||
}
|
||
renderStemEnergyBaseline(stems, decoded);
|
||
startStemVuLoop(stems, decoded, token);
|
||
for (const stem of stems) {
|
||
const buf = decoded.get(stem.name);
|
||
if (isAudioBufferLike(buf)) {
|
||
renderDecodedStemVisuals(stem.name, buf, STEM_COLORS[stem.name] || "#a0a0a0");
|
||
}
|
||
}
|
||
}).catch((e) => {
|
||
console.warn("[player] audio engine init failed; using streaming path:", e);
|
||
eng.destroy();
|
||
if (audioEngine === eng) setAudioEngine(null);
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
export function drawFooterPlaceholder() {
|
||
_drawPlaceholderWave();
|
||
const bar = document.getElementById("footer-waveform")?.closest(".footer-wave-bar");
|
||
if (bar) {
|
||
_footerPlaceholderResizeObs?.disconnect();
|
||
_footerPlaceholderResizeObs = new ResizeObserver(() => { if (!_footerWavePeaks) _drawPlaceholderWave(); });
|
||
_footerPlaceholderResizeObs.observe(bar);
|
||
}
|
||
}
|
||
|
||
function _drawPlaceholderWave() {
|
||
const canvas = document.getElementById("footer-waveform");
|
||
if (!canvas) return;
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||
if (!w || !h) return;
|
||
canvas.width = Math.round(w * dpr);
|
||
canvas.height = Math.round(h * dpr);
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const n = 300;
|
||
const barW = canvas.width / n;
|
||
const gap = 1;
|
||
const cy = canvas.height / 2;
|
||
for (let i = 0; i < n; i++) {
|
||
const t = i / n;
|
||
const amp = 0.15 + 0.55 * (
|
||
0.5 * Math.abs(Math.sin(t * Math.PI * 3.7 + 0.5)) +
|
||
0.3 * Math.abs(Math.sin(t * Math.PI * 9.1 + 1.2)) +
|
||
0.2 * Math.abs(Math.sin(t * Math.PI * 21.3 + 2.8))
|
||
);
|
||
const barH = Math.max(3, amp * canvas.height * 0.78);
|
||
const x = Math.round(i * barW);
|
||
const nextX = i === n - 1 ? canvas.width : Math.round((i + 1) * barW);
|
||
const bw = Math.max(1, nextX - x - gap);
|
||
ctx.fillStyle = "rgba(255,255,255,0.07)";
|
||
ctx.fillRect(x, cy - barH / 2, bw, barH);
|
||
}
|
||
}
|
||
|
||
function _drawFooterWave(progress) {
|
||
_lastFooterProgress = progress;
|
||
const canvas = document.getElementById("footer-waveform");
|
||
if (!canvas) return;
|
||
if (!_footerWavePeaks) { _drawPlaceholderWave(); return; }
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const w = canvas.clientWidth;
|
||
const h = canvas.clientHeight;
|
||
if (!w || !h) return;
|
||
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
|
||
canvas.width = Math.round(w * dpr);
|
||
canvas.height = Math.round(h * dpr);
|
||
}
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
const n = _footerWavePeaks.length;
|
||
let maxAbs = 0;
|
||
for (const [mn, mx] of _footerWavePeaks) {
|
||
if (mx > maxAbs) maxAbs = mx;
|
||
if (-mn > maxAbs) maxAbs = -mn;
|
||
}
|
||
const norm = maxAbs > 0 ? 1 / maxAbs : 1;
|
||
const cy = canvas.height / 2;
|
||
const barW = canvas.width / n;
|
||
const gap = 1;
|
||
const playedIdx = Math.floor(progress * n);
|
||
for (let i = 0; i < n; i++) {
|
||
const [mn, mx] = _footerWavePeaks[i];
|
||
const top = cy - mx * norm * cy * 0.78;
|
||
const bot = cy - mn * norm * cy * 0.78;
|
||
const barH = Math.max(3, bot - top);
|
||
const x = Math.round(i * barW);
|
||
const nextX = i === n - 1 ? canvas.width : Math.round((i + 1) * barW);
|
||
const bw = Math.max(1, nextX - x - gap);
|
||
ctx.fillStyle = i < playedIdx ? "#f4b740" : "rgba(255,255,255,0.13)";
|
||
ctx.fillRect(x, top, bw, barH);
|
||
}
|
||
if (progress > 0.001 && progress < 0.999) {
|
||
const px = progress * canvas.width;
|
||
ctx.beginPath();
|
||
ctx.arc(px, cy, 4 * dpr, 0, Math.PI * 2);
|
||
ctx.fillStyle = "#f4b740";
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
let _footerWaveResizeObs = null;
|
||
async function initFooterWaveform(stemUrl) {
|
||
const canvas = document.getElementById("footer-waveform");
|
||
if (!canvas || !stemUrl) return;
|
||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||
if (!AudioCtx) return;
|
||
try {
|
||
visualAudioContext ??= new AudioCtx();
|
||
const res = await fetch(stemUrl, { cache: "force-cache" });
|
||
if (!res.ok) return;
|
||
const buf = await visualAudioContext.decodeAudioData(await res.arrayBuffer());
|
||
_footerWavePeaks = bufferMinMaxPeaks(buf, _FOOTER_BARS);
|
||
setFooterWaveDrawFn(_drawFooterWave);
|
||
_drawFooterWave(0);
|
||
// Redraw on resize so the canvas fills its container correctly
|
||
_footerWaveResizeObs?.disconnect();
|
||
_footerWaveResizeObs = new ResizeObserver(() => _drawFooterWave(_lastFooterProgress));
|
||
_footerWaveResizeObs.observe(canvas.parentElement || canvas);
|
||
} catch (e) {
|
||
console.warn("[player] footer waveform:", e);
|
||
}
|
||
}
|
||
|
||
export function updateFooterTrack({ title, thumbnail, key, bpm, stemCount } = {}) {
|
||
if (footerThumb) {
|
||
const artEl = footerThumb.closest(".footer-art");
|
||
if (thumbnail) {
|
||
footerThumb.src = thumbnail;
|
||
footerThumb.onload = () => {
|
||
footerThumb.classList.add("loaded");
|
||
artEl?.classList.add("has-art");
|
||
};
|
||
footerThumb.onerror = () => {
|
||
footerThumb.classList.remove("loaded");
|
||
artEl?.classList.remove("has-art");
|
||
};
|
||
} else {
|
||
footerThumb.removeAttribute("src");
|
||
footerThumb.classList.remove("loaded");
|
||
artEl?.classList.remove("has-art");
|
||
}
|
||
}
|
||
if (footerTitle && title !== undefined) footerTitle.textContent = title;
|
||
if (footerMeta) {
|
||
const parts = [];
|
||
if (key) parts.push(key);
|
||
if (bpm) parts.push(`${Math.round(bpm)} BPM`);
|
||
if (stemCount != null) parts.push(`${stemCount} Stems`);
|
||
if (key !== undefined || bpm !== undefined || stemCount !== undefined)
|
||
footerMeta.textContent = parts.join(" • ");
|
||
}
|
||
}
|
||
|
||
function _triggerDownload(url, filename) {
|
||
const fullUrl = url.startsWith("http") ? url : `${location.origin}${url}`;
|
||
if (window.__TAURI__?.core?.invoke) {
|
||
window.__TAURI__.core.invoke("save_audio_file", { url: fullUrl, filename });
|
||
return;
|
||
}
|
||
const a = document.createElement("a");
|
||
a.href = fullUrl;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
}
|
||
|
||
// Per-lane effective gain mirroring mixer.js applyMix (volume + mute + solo),
|
||
// minus the master fader (a monitoring level, not part of the mix). Returns the
|
||
// audible lanes and their gains so the backend can render a mixdown matching what
|
||
// is heard. _currentStems already includes "original" when the user picked a
|
||
// subset, so summing these lanes reconstructs the adjusted full song.
|
||
function _effectiveMixGains() {
|
||
const anySolo = _currentStems.some((s) => mixerState[s.name]?.soloed);
|
||
const names = [];
|
||
const gains = [];
|
||
for (const s of _currentStems) {
|
||
if (s.name === "mix") continue;
|
||
const m = mixerState[s.name];
|
||
if (!m) continue;
|
||
const g = m.muted ? 0 : (anySolo && !m.soloed ? 0 : m.volume);
|
||
if (g <= 0) continue;
|
||
names.push(s.name);
|
||
gains.push(Math.max(0, Math.min(LANE_VOLUME_MAX, g)));
|
||
}
|
||
return { names, gains };
|
||
}
|
||
|
||
// Dynamic mixdown URL for the current mixer state. Returns null (no download)
|
||
// when every lane is silenced; `region` appends the loop bounds.
|
||
function _mixdownUrl(ext, region) {
|
||
if (!currentJobId) return null;
|
||
const { names, gains } = _effectiveMixGains();
|
||
if (!names.length) return null;
|
||
const q = new URLSearchParams({
|
||
stems: names.join(","),
|
||
gains: gains.map((g) => g.toFixed(3)).join(","),
|
||
});
|
||
if (region) {
|
||
q.set("start", loopStart.toFixed(3));
|
||
q.set("end", loopEnd.toFixed(3));
|
||
}
|
||
return `/api/jobs/${currentJobId}/mixdown.${ext}?${q}`;
|
||
}
|
||
|
||
function _exportFilename(ext) {
|
||
const safe = _currentTitle
|
||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||
.replace(/_{2,}/g, "_")
|
||
.slice(0, 80)
|
||
.replace(/^_+|_+$/g, "");
|
||
return safe ? `${safe}_exported_mix.${ext}` : `exported_mix.${ext}`;
|
||
}
|
||
|
||
// The download functions return true when a download was triggered and false
|
||
// when there is nothing audible to export (every lane muted), so the caller can
|
||
// surface a message.
|
||
export function downloadCurrentMix(ext = "wav") {
|
||
const url = _mixdownUrl(ext, false);
|
||
if (!url) return false;
|
||
_triggerDownload(url, _exportFilename(ext));
|
||
return true;
|
||
}
|
||
|
||
// MP4 export: the preserved source video muxed with the current audio mix.
|
||
// Only meaningful for mp4-sourced jobs (currentJobHasVideo()); returns false when
|
||
// there's no video track or every lane is muted.
|
||
export function downloadCurrentVideo() {
|
||
if (!currentJobId || !_currentHasVideo) return false;
|
||
const { names, gains } = _effectiveMixGains();
|
||
if (!names.length) return false;
|
||
const q = new URLSearchParams({
|
||
stems: names.join(","),
|
||
gains: gains.map((g) => g.toFixed(3)).join(","),
|
||
});
|
||
const safe = _currentTitle
|
||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||
.replace(/_{2,}/g, "_")
|
||
.slice(0, 80)
|
||
.replace(/^_+|_+$/g, "");
|
||
const name = safe ? `${safe}_video.mp4` : "video.mp4";
|
||
_triggerDownload(`/api/jobs/${currentJobId}/video.mp4?${q}`, name);
|
||
return true;
|
||
}
|
||
|
||
export function downloadCurrentStems(format = "wav", onProgress) {
|
||
const stems = _currentStems.filter((s) => s.name !== "original");
|
||
const total = stems.length;
|
||
if (!total) { onProgress?.(0, 0); return; }
|
||
// Name each file "<song title>_<instrument>.<ext>" using the same title
|
||
// sanitization as the mix/region exports.
|
||
const safe = _currentTitle
|
||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||
.replace(/_{2,}/g, "_")
|
||
.slice(0, 80)
|
||
.replace(/^_+|_+$/g, "");
|
||
stems.forEach((s, i) => {
|
||
window.setTimeout(() => {
|
||
const url = format === "mp3" ? s.url.replace(/\.wav(\?|$)/, ".mp3$1") : s.url;
|
||
const fname = safe ? `${safe}_${s.name}.${format}` : `${s.name}.${format}`;
|
||
_triggerDownload(url, fname);
|
||
onProgress?.(i + 1, total);
|
||
}, i * 150);
|
||
});
|
||
}
|
||
|
||
export function downloadAllStemsZip(format = "wav") {
|
||
if (!currentJobId) return;
|
||
// Only the active (selected) stems loaded in the DAW — not all 6.
|
||
const names = _currentStems.filter((s) => s.name !== "original").map((s) => s.name);
|
||
if (!names.length) return;
|
||
const safe = _currentTitle
|
||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||
.replace(/_{2,}/g, "_")
|
||
.slice(0, 80)
|
||
.replace(/^_+|_+$/g, "");
|
||
const name = safe ? `${safe}_stems.zip` : "stems.zip";
|
||
const q = new URLSearchParams({ format, stems: names.join(",") });
|
||
_triggerDownload(`/api/jobs/${currentJobId}/stems/all.zip?${q}`, name);
|
||
}
|
||
|
||
function _regionFilename(ext) {
|
||
const safe = _currentTitle
|
||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||
.replace(/_{2,}/g, "_")
|
||
.slice(0, 80)
|
||
.replace(/^_+|_+$/g, "");
|
||
return `${safe || "region"}_region.${ext}`;
|
||
}
|
||
|
||
export function downloadRegionMix(ext = "wav") {
|
||
if (!loopEnabled || loopStart >= loopEnd) return false;
|
||
const url = _mixdownUrl(ext, true);
|
||
if (!url) return false;
|
||
_triggerDownload(url, _regionFilename(ext));
|
||
return true;
|
||
}
|