import { STEM_NAMES, TRACK_NAMES, STEM_COLORS, STEM_DISPLAY, LANE_VOLUME_MAX, } from "./constants.js"; import { mixerState, mixerEl, stemListEl, currentJobId, multitrack, trackIndex, masterVolume, audioEngine, } from "./state.js"; import { storeGet, storeSetDebounced } from "./utils.js"; function defaultMixerEntry() { return { volume: 1, muted: false, soloed: false }; } export function ensureMixerStateDefaults() { for (const name of TRACK_NAMES) { if (!mixerState[name]) mixerState[name] = defaultMixerEntry(); } } export async function loadMixIntoState(jobId, loadedStemNames = STEM_NAMES) { let stored = {}; try { const data = await storeGet(`stemdeck:mix:${jobId}`, {}); if (data && typeof data === "object") stored = data; } catch (e) { console.warn("[mixer] failed to load mix state:", e); } for (const name of TRACK_NAMES) { Object.assign(mixerState[name], defaultMixerEntry(), stored[name] || {}); } // If all loaded stems are muted the session is unplayable -- unmute as recovery. const loadedStems = loadedStemNames.filter((n) => mixerState[n]); if (loadedStems.length > 0 && loadedStems.every((n) => mixerState[n].muted)) { for (const name of loadedStems) mixerState[name].muted = false; } } export function resetMixerState() { for (const name of TRACK_NAMES) { Object.assign(mixerState[name], defaultMixerEntry()); } } function saveMix() { if (!currentJobId) return; storeSetDebounced(`stemdeck:mix:${currentJobId}`, mixerState); } export function applyMix() { if (!multitrack) return; const anySolo = TRACK_NAMES.some((name) => trackIndex[name] !== undefined && mixerState[name]?.soloed); for (const name of TRACK_NAMES) { const s = mixerState[name]; if (!s) continue; let effective = s.volume; if (s.muted) effective = 0; else if (anySolo && !s.soloed) effective = 0; const idx = trackIndex[name]; if (idx === undefined) continue; const targetGain = effective * masterVolume; if (audioEngine) { // Web Audio engine owns playback: set the per-stem gain directly (no 1.0 // cap, so >1.0 lane boost works), and skip the streaming volume path — // the multitrack is mounted for visuals only and never plays. audioEngine.setGain(name, targetGain); continue; } const audioEl = multitrack.audios?.[idx]; if (audioEl instanceof HTMLMediaElement) { // WKWebView does not pass audio through MediaElementSource → GainNode → // destination. Use the native HTMLAudioElement volume path instead and // cap at 1.0 (the spec limit). Boost above unity is not supported on // this platform but basic volume/mute works reliably. audioEl.volume = Math.max(0, Math.min(1, targetGain)); } else { multitrack.setTrackVolume(idx, targetGain); } } } export function updateLaneKnobVisual(knobEl, v) { const frac = Math.max(0, Math.min(1, v / LANE_VOLUME_MAX)); knobEl.style.setProperty("--lane-pos", frac.toFixed(3)); knobEl.setAttribute("aria-valuenow", v.toFixed(2)); const input = knobEl.querySelector(".mx-fader-input"); if (input) { input.value = String(v); // Set --lane-pos directly on the input so ::webkit-slider-runnable-track // can see it — WebKit shadow DOM pseudo-elements don't inherit vars from ancestors. input.style.setProperty("--lane-pos", frac.toFixed(3)); } const val = knobEl.closest(".lane-header")?.querySelector(".mx-val"); if (val) { const db = v <= 0 ? "-∞" : (20 * Math.log10(v)).toFixed(1); val.textContent = db === "-∞" ? "-∞" : `${parseFloat(db) > 0 ? "+" : ""}${db}`; } } export function setLaneVolume(name, v) { const state = mixerState[name]; if (!state) return; state.volume = Math.max(0, Math.min(LANE_VOLUME_MAX, v)); const knob = mixerEl.querySelector(`.lane-knob[data-stem="${name}"]`); if (knob) updateLaneKnobVisual(knob, state.volume); applyMix(); saveMix(); } export function refreshMixerVisuals() { for (const name of TRACK_NAMES) { const state = mixerState[name]; if (!state) continue; // Mixer-column lane header const row = mixerEl.querySelector(`.lane-header[data-stem="${name}"]`); if (row) { const muteBtn = row.querySelector(".mute"); const soloBtn = row.querySelector(".solo"); if (soloBtn) soloBtn.classList.toggle("active", state.soloed); const iconToggle = row.querySelector(".lane-icon-toggle"); if (iconToggle) { iconToggle.classList.toggle("active", !state.muted); iconToggle.setAttribute("aria-pressed", String(!state.muted)); } row.classList.toggle("muted", state.muted); const knob = row.querySelector(".lane-knob"); if (knob) updateLaneKnobVisual(knob, state.volume); } // Stems-list panel row (mirrors the mixer column buttons) if (stemListEl) { const slRow = stemListEl.querySelector(`span[data-stem="${name}"]`); if (slRow) { const m = slRow.querySelector(".stem-mute"); const s = slRow.querySelector(".stem-solo"); const mon = slRow.querySelector(".stem-monitor"); if (m) { m.classList.toggle("active", state.muted); m.setAttribute("aria-pressed", String(state.muted)); } if (s) { s.classList.toggle("active", state.soloed); s.setAttribute("aria-pressed", String(state.soloed)); } if (mon) { // Active when this stem is THE lone solo (the "monitor" target). const others = TRACK_NAMES.filter((n) => n !== name); const lone = state.soloed && others.every((n) => !mixerState[n]?.soloed); mon.classList.toggle("active", lone); } slRow.classList.toggle("muted", state.muted); } } } } export function setLaneControlsEnabled(enabled) { for (const b of mixerEl.querySelectorAll(".ms-btn")) b.disabled = !enabled; for (const b of mixerEl.querySelectorAll(".lane-icon-toggle")) b.disabled = !enabled; for (const a of mixerEl.querySelectorAll(".lane-dl")) { a.classList.toggle("disabled", !enabled); if (!enabled) { a.setAttribute("aria-disabled", "true"); a.setAttribute("tabindex", "-1"); } else { a.removeAttribute("aria-disabled"); a.removeAttribute("tabindex"); } } for (const k of mixerEl.querySelectorAll(".lane-knob")) { k.classList.toggle("disabled", !enabled); k.setAttribute("aria-disabled", String(!enabled)); k.setAttribute("tabindex", enabled ? "0" : "-1"); } } const MINI_WAVE_BARS = 40; const MINI_WAVE_VIEWBOX_H = 26; function emptyMiniWaveSvg(stemName) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("class", "lane-mini-wave"); svg.dataset.stem = stemName; svg.setAttribute("preserveAspectRatio", "none"); svg.setAttribute("viewBox", `0 0 ${MINI_WAVE_BARS * 2} ${MINI_WAVE_VIEWBOX_H}`); return svg; } function makeMiniWaveSvg(stemName, color) { // Seeded placeholder bars used while real peaks haven't loaded yet. let s = 0; for (const c of stemName) s = (s * 31 + c.charCodeAt(0)) >>> 0; const rng = () => { s = (s * 9301 + 49297) % 233280; return s / 233280; }; const svg = emptyMiniWaveSvg(stemName); for (let i = 0; i < MINI_WAVE_BARS; i++) { const env = Math.sin((i / MINI_WAVE_BARS) * Math.PI) * 0.7 + 0.3; const h = Math.max(2, env * (rng() * 0.6 + 0.25) * MINI_WAVE_VIEWBOX_H); const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect.setAttribute("x", `${i * 2}`); rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`); rect.setAttribute("width", "1"); rect.setAttribute("height", `${h}`); rect.setAttribute("fill", color); rect.setAttribute("opacity", "0.6"); svg.appendChild(rect); } return svg; } export function renderRealMiniWave(stemName, audioBuffer, color) { const svg = mixerEl.querySelector(`.lane-mini-wave[data-stem="${stemName}"]`); if (!svg || !audioBuffer || typeof audioBuffer.getChannelData !== "function") return; const ch = audioBuffer.getChannelData(0); if (!ch || !ch.length) return; const binSize = Math.max(1, Math.floor(ch.length / MINI_WAVE_BARS)); const peaks = new Array(MINI_WAVE_BARS); let max = 0; for (let i = 0; i < MINI_WAVE_BARS; i++) { const start = i * binSize; const end = i === MINI_WAVE_BARS - 1 ? ch.length : start + binSize; let p = 0; for (let j = start; j < end; j++) { const v = Math.abs(ch[j]); if (v > p) p = v; } peaks[i] = p; if (p > max) max = p; } const norm = max > 0 ? 1 / max : 0; while (svg.firstChild) svg.removeChild(svg.firstChild); for (let i = 0; i < MINI_WAVE_BARS; i++) { const h = Math.max(1.5, peaks[i] * norm * MINI_WAVE_VIEWBOX_H); const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); rect.setAttribute("x", `${i * 2}`); rect.setAttribute("y", `${(MINI_WAVE_VIEWBOX_H - h) / 2}`); rect.setAttribute("width", "1"); rect.setAttribute("height", `${h}`); rect.setAttribute("fill", color); rect.setAttribute("opacity", "0.95"); svg.appendChild(rect); } } function downloadIcon() { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 24 24"); svg.setAttribute("width", "18"); svg.setAttribute("height", "18"); svg.setAttribute("fill", "none"); svg.setAttribute("stroke", "currentColor"); svg.setAttribute("stroke-width", "2"); svg.setAttribute("aria-hidden", "true"); svg.innerHTML = '' + '' + ''; return svg; } function makeVolumeKnob(stemName, color) { const wrap = document.createElement("div"); wrap.className = "lane-knob mx-fader"; wrap.dataset.stem = stemName; const input = document.createElement("input"); input.type = "range"; input.className = "mx-fader-input"; input.setAttribute("min", "0"); input.setAttribute("max", String(LANE_VOLUME_MAX)); input.setAttribute("step", "0.01"); input.setAttribute("value", "1"); input.style.setProperty("--fader-color", color); input.style.setProperty("--lane-pos", "0.5"); input.setAttribute("aria-label", `${STEM_DISPLAY[stemName] || stemName} volume`); input.addEventListener("input", () => setLaneVolume(stemName, parseFloat(input.value))); input.addEventListener("dblclick", (e) => { e.stopPropagation(); setLaneVolume(stemName, 1); }); wrap.appendChild(input); wrap.addEventListener("dblclick", () => setLaneVolume(stemName, 1)); wrap.addEventListener("wheel", (e) => { e.preventDefault(); const cur = mixerState[stemName]?.volume ?? 1; setLaneVolume(stemName, cur - Math.sign(e.deltaY) * (e.shiftKey ? 0.2 : 0.04)); }, { passive: false }); return wrap; } function stemIconMarkup(stemName) { const common = 'class="lane-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"'; const icons = { vocals: ``, drums: ``, bass: ``, guitar: ``, piano: ``, other: ``, original: ``, }; return icons[stemName] || icons.other; } export function renderMixerRow(stem) { const state = mixerState[stem.name]; const color = STEM_COLORS[stem.name] || "#a0a0a0"; const display = STEM_DISPLAY[stem.name] || stem.name; const row = document.createElement("div"); row.className = "lane-header mx-row"; row.dataset.stem = stem.name; // Col 1: stem icon const iconCell = document.createElement("div"); iconCell.className = "mx-icon"; iconCell.style.color = color; iconCell.innerHTML = stemIconMarkup(stem.name); // Hidden lane-stripe kept for CSS compat const stripe = document.createElement("div"); stripe.className = "lane-stripe"; stripe.style.background = color; row.appendChild(stripe); // Col 2: name const nameEl = document.createElement("span"); nameEl.className = "mx-name"; nameEl.style.color = color; nameEl.textContent = display; // Col 3: horizontal fader const fader = makeVolumeKnob(stem.name, color); // Col 4: VU meter const vu = document.createElement("div"); vu.className = "lane-vu mx-meter"; vu.dataset.stem = stem.name; vu.innerHTML = '
'; // Col 5: value label const val = document.createElement("span"); val.className = "mx-val"; const initFrac = Math.max(0, Math.min(1, (state?.volume ?? 1) / LANE_VOLUME_MAX)); val.textContent = String(Math.round(initFrac * 100)); // Col 6: M button const muteBtn = document.createElement("button"); muteBtn.type = "button"; muteBtn.className = "lane-icon-toggle mx-btn mute"; muteBtn.textContent = "M"; muteBtn.setAttribute("aria-label", `Mute ${display}`); muteBtn.setAttribute("aria-pressed", String(state?.muted ?? false)); if (!state?.muted) muteBtn.classList.add("active"); // Col 7: S button const soloBtn = document.createElement("button"); soloBtn.type = "button"; soloBtn.className = "solo ms-btn mx-btn"; soloBtn.textContent = "S"; soloBtn.setAttribute("aria-label", `Solo ${display}`); soloBtn.setAttribute("aria-pressed", String(state?.soloed ?? false)); if (state?.soloed) soloBtn.classList.add("active"); // Col 8: download const dl = document.createElement("a"); dl.className = "lane-dl mx-btn"; dl.href = stem.url; dl.download = `${stem.name}.wav`; dl.title = `Download ${display}`; dl.appendChild(downloadIcon()); // Wrap name + VU in a column so VU appears below the name const nameVuCol = document.createElement("div"); nameVuCol.className = "lane-name-vu"; nameVuCol.append(nameEl, vu); row.append(iconCell, nameVuCol, fader, val, muteBtn, soloBtn, dl); muteBtn.addEventListener("click", () => toggleStemMute(stem.name)); soloBtn.addEventListener("click", () => toggleStemSolo(stem.name)); row.classList.toggle("muted", state?.muted ?? false); if (state) updateLaneKnobVisual(fader, state.volume); return { row, vuEl: vu }; } // ─── Stem-list panel (Stems sidebar) ─── // // The stems-list panel renders a parallel set of M / S / Monitor controls // that share state with the mixer-column lane-header buttons. Both UIs // drive `mixerState`; either one updates the audio mix and both visuals // re-render via refreshMixerVisuals(). export function toggleStemMute(name) { const state = mixerState[name]; if (!state) return; state.muted = !state.muted; refreshMixerVisuals(); applyMix(); saveMix(); } export function toggleStemSolo(name) { const state = mixerState[name]; if (!state) return; state.soloed = !state.soloed; refreshMixerVisuals(); applyMix(); saveMix(); } // "Monitor" = solo only this stem. If already the lone solo, clear all // solos (toggle-style behavior, like Logic's "Solo Safe" / Reaper's // solo-exclusive). Also clears mute on the target so it's audible. export function soloOnlyStem(name) { const state = mixerState[name]; if (!state) return; const others = TRACK_NAMES.filter((n) => n !== name); const isAlreadyAlone = state.soloed && others.every((n) => !mixerState[n]?.soloed); if (isAlreadyAlone) { state.soloed = false; } else { for (const n of TRACK_NAMES) { if (!mixerState[n]) continue; mixerState[n].soloed = (n === name); } state.muted = false; } refreshMixerVisuals(); applyMix(); saveMix(); } export function resetMixer() { for (const name of TRACK_NAMES) { const s = mixerState[name]; if (!s) continue; s.volume = 1; s.muted = false; s.soloed = false; } refreshMixerVisuals(); applyMix(); saveMix(); } export function muteAll() { // Toggle: if every stem is muted, un-mute all; otherwise mute all. const allMuted = STEM_NAMES.every((n) => mixerState[n]?.muted); for (const name of TRACK_NAMES) { const s = mixerState[name]; if (!s) continue; s.muted = !allMuted; } refreshMixerVisuals(); applyMix(); saveMix(); } export function clearAllSolos() { for (const name of TRACK_NAMES) { const s = mixerState[name]; if (!s) continue; s.soloed = false; } refreshMixerVisuals(); applyMix(); saveMix(); } export function wireMixerToolbar() { document.getElementById("mixer-reset")?.addEventListener("click", resetMixer); document.getElementById("mixer-mute-all")?.addEventListener("click", muteAll); document.getElementById("mixer-clear-solo")?.addEventListener("click", clearAllSolos); } export function wireStemListControls() { if (!stemListEl) return; for (const btn of stemListEl.querySelectorAll(".stem-mute")) { btn.addEventListener("click", () => toggleStemMute(btn.dataset.stem)); btn.addEventListener("keydown", (e) => { if (e.code === "Space" || e.code === "Enter") { e.preventDefault(); toggleStemMute(btn.dataset.stem); } }); } for (const btn of stemListEl.querySelectorAll(".stem-solo")) { btn.addEventListener("click", () => toggleStemSolo(btn.dataset.stem)); btn.addEventListener("keydown", (e) => { if (e.code === "Space" || e.code === "Enter") { e.preventDefault(); toggleStemSolo(btn.dataset.stem); } }); } for (const btn of stemListEl.querySelectorAll(".stem-monitor")) { btn.addEventListener("click", () => soloOnlyStem(btn.dataset.stem)); } }