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 =
'