404 lines
12 KiB
JavaScript
Executable File
404 lines
12 KiB
JavaScript
Executable File
// js/pattern/pattern_state.js
|
|
import * as Tone from "https://esm.sh/tone";
|
|
import { TripleOscillator } from "../../audio/plugins/TripleOscillator.js";
|
|
import { Kicker } from "../../audio/plugins/Kicker.js";
|
|
import { Lb302 } from "../../audio/plugins/Lb302.js";
|
|
import { Nes } from "../../audio/plugins/Nes.js";
|
|
import { SuperSaw } from "../../audio/plugins/SuperSaw.js";
|
|
|
|
import { appState } from "../state.js";
|
|
import { DEFAULT_VOLUME, DEFAULT_PAN } from "../config.js";
|
|
import { getMainGainNode } from "../audio.js";
|
|
import { getTotalSteps } from "../utils.js";
|
|
|
|
// XML padrão para o instrumento Kicker (bateria simples)
|
|
const DEFAULT_KICKER_XML = `<kicker>
|
|
<env amt="0" attack="0.01" hold="0.1" decay="0.1" release="0.1" sustain="0.5" sync_mode="0"/>
|
|
</kicker>`;
|
|
|
|
// ------------------------------------------------------------
|
|
// MIX / MUTE HELPERS (Volume, Pan)
|
|
// ------------------------------------------------------------
|
|
const MAX_TRACK_VOLUME = 1.5;
|
|
|
|
function _clamp(n, min, max) {
|
|
const x = Number(n);
|
|
if (!Number.isFinite(x)) return min;
|
|
return Math.min(max, Math.max(min, x));
|
|
}
|
|
|
|
function _ensureMuteFields(track) {
|
|
if (!track) return;
|
|
if (track.isMuted == null) track.isMuted = !!track.muted;
|
|
if (track.muted == null) track.muted = !!track.isMuted;
|
|
}
|
|
|
|
function applyTrackMix(track) {
|
|
if (!track) return;
|
|
|
|
_ensureMuteFields(track);
|
|
|
|
const vol = _clamp(track.volume ?? DEFAULT_VOLUME, 0, MAX_TRACK_VOLUME);
|
|
const pan = _clamp(track.pan ?? DEFAULT_PAN, -1, 1);
|
|
const isMuted = !!(track.isMuted || track.muted);
|
|
|
|
// Volume (dB): mute => -Infinity
|
|
if (!track.volumeNode) {
|
|
track.volumeNode = new Tone.Volume(
|
|
isMuted || vol === 0 ? -Infinity : Tone.gainToDb(vol)
|
|
);
|
|
} else {
|
|
track.volumeNode.volume.value =
|
|
isMuted || vol === 0 ? -Infinity : Tone.gainToDb(vol);
|
|
}
|
|
|
|
// Pan
|
|
if (!track.pannerNode) {
|
|
track.pannerNode = new Tone.Panner(pan);
|
|
} else {
|
|
track.pannerNode.pan.value = pan;
|
|
}
|
|
|
|
// reconecta cadeia base (idempotente)
|
|
try { track.volumeNode.disconnect(); } catch {}
|
|
try { track.pannerNode.disconnect(); } catch {}
|
|
track.volumeNode.connect(track.pannerNode);
|
|
track.pannerNode.connect(getMainGainNode());
|
|
}
|
|
|
|
// API pública (usada pelo UI/socket)
|
|
export function setPatternTrackMute(trackId, isMuted) {
|
|
const t = (appState.pattern.tracks || []).find((x) => String(x.id) === String(trackId));
|
|
if (!t) return false;
|
|
t.isMuted = !!isMuted;
|
|
t.muted = !!isMuted;
|
|
applyTrackMix(t);
|
|
return true;
|
|
}
|
|
|
|
export function setPatternTrackVolume(trackId, volume) {
|
|
const t = (appState.pattern.tracks || []).find((x) => String(x.id) === String(trackId));
|
|
if (!t) return false;
|
|
t.volume = _clamp(volume, 0, MAX_TRACK_VOLUME);
|
|
applyTrackMix(t);
|
|
return true;
|
|
}
|
|
|
|
export function setPatternTrackPan(trackId, pan) {
|
|
const t = (appState.pattern.tracks || []).find((x) => String(x.id) === String(trackId));
|
|
if (!t) return false;
|
|
t.pan = _clamp(pan, -1, 1);
|
|
applyTrackMix(t);
|
|
return true;
|
|
}
|
|
|
|
export function initializePatternState() {
|
|
appState.pattern.tracks.forEach(track => {
|
|
try { track.player?.dispose(); } catch {}
|
|
try { track.buffer?.dispose?.(); } catch {}
|
|
try { track.instrument?.dispose(); } catch {}
|
|
try { track.previewPlayer?.dispose(); } catch {}
|
|
});
|
|
|
|
appState.pattern.tracks = [];
|
|
appState.pattern.activeTrackId = null;
|
|
appState.pattern.activePatternIndex = null;
|
|
}
|
|
|
|
export async function loadAudioForTrack(track) {
|
|
// 1) Garante Volume/Pan/Mute e reconecta a cadeia base
|
|
try {
|
|
applyTrackMix(track);
|
|
|
|
// desconecta o que existir (para reconectar sem duplicar rotas)
|
|
try { track.instrument?.disconnect(); } catch {}
|
|
try { track.player?.disconnect(); } catch {}
|
|
try { track.previewPlayer?.disconnect(); } catch {}
|
|
} catch (e) {
|
|
console.error("Erro ao criar nós de áudio base:", e);
|
|
}
|
|
|
|
// 2) Detecta se é um arquivo de áudio suportado
|
|
const isStandardAudio =
|
|
track.samplePath && /\.(wav|mp3|ogg|flac|m4a)$/i.test(track.samplePath);
|
|
|
|
// 3) Se não for áudio padrão → Plugin (ou fallback)
|
|
if (!track.samplePath || !isStandardAudio) {
|
|
try {
|
|
// limpa sampler/preview/buffer
|
|
try { track.player?.dispose(); } catch {}
|
|
try { track.previewPlayer?.dispose(); } catch {}
|
|
try { track.buffer?.dispose?.(); } catch {}
|
|
track.player = null;
|
|
track.previewPlayer = null;
|
|
track.buffer = null;
|
|
|
|
if (track.instrument) {
|
|
try { track.instrument.dispose(); } catch {}
|
|
track.instrument = null;
|
|
}
|
|
|
|
let synth;
|
|
const name = (track.instrumentName || "kicker").toLowerCase();
|
|
const pluginData = {};
|
|
|
|
switch (name) {
|
|
case "tripleoscillator":
|
|
case "3osc":
|
|
synth = new TripleOscillator(Tone.getContext(), pluginData);
|
|
break;
|
|
|
|
case "kicker":
|
|
synth = new Kicker(Tone.getContext(), pluginData);
|
|
break;
|
|
|
|
case "lb302":
|
|
synth = new Lb302(Tone.getContext(), pluginData);
|
|
break;
|
|
|
|
case "nes":
|
|
case "freeboy":
|
|
case "papu":
|
|
case "sid":
|
|
synth = new Nes(Tone.getContext(), pluginData);
|
|
break;
|
|
|
|
case "zynaddsubfx":
|
|
case "watsyn":
|
|
case "monstro":
|
|
case "vibedstrings":
|
|
case "supersaw":
|
|
synth = new SuperSaw(Tone.getContext(), pluginData);
|
|
break;
|
|
|
|
case "organic":
|
|
synth = new Tone.PolySynth(Tone.Synth, {
|
|
oscillator: { type: "sine", count: 8, spread: 20 },
|
|
});
|
|
break;
|
|
|
|
default:
|
|
console.warn(`Plugin ${name} desconhecido, usando fallback (Kicker).`);
|
|
synth = new Kicker(Tone.getContext(), pluginData);
|
|
}
|
|
|
|
// Conecta plugin na cadeia
|
|
if (synth.output) synth.connect(track.volumeNode);
|
|
else synth.connect(track.volumeNode);
|
|
|
|
track.instrument = synth;
|
|
track.type = "plugin";
|
|
|
|
if (!track.instrumentName) track.instrumentName = name;
|
|
|
|
console.log(`[Audio] Plugin carregado: ${name}`);
|
|
return track;
|
|
} catch (e) {
|
|
console.error("Erro ao carregar plugin:", track.instrumentName, e);
|
|
return track;
|
|
}
|
|
}
|
|
|
|
// 4) SAMPLER (áudio)
|
|
try {
|
|
// limpa plugin
|
|
if (track.instrument) {
|
|
try { track.instrument.dispose(); } catch {}
|
|
track.instrument = null;
|
|
}
|
|
|
|
// limpa players/buffer antigos
|
|
try { track.player?.dispose(); } catch {}
|
|
try { track.previewPlayer?.dispose(); } catch {}
|
|
try { track.buffer?.dispose?.(); } catch {}
|
|
track.player = null;
|
|
track.previewPlayer = null;
|
|
track.buffer = null;
|
|
|
|
// Player principal (Playlist/steps)
|
|
const player = new Tone.Player({
|
|
url: track.samplePath,
|
|
autostart: false,
|
|
retrigger: true,
|
|
});
|
|
|
|
// redundância segura p/ builds diferentes do Tone:
|
|
try { player.retrigger = true; } catch {}
|
|
|
|
await player.load(track.samplePath);
|
|
player.connect(track.volumeNode);
|
|
|
|
// ✅ reutiliza o MESMO buffer do player (sem segundo download)
|
|
track.buffer = player.buffer;
|
|
track.player = player;
|
|
|
|
// Preview player (Pattern Editor) — separado pra não brigar com a playlist
|
|
const previewPlayer = new Tone.Player({
|
|
autostart: false,
|
|
retrigger: true,
|
|
});
|
|
try { previewPlayer.retrigger = true; } catch {}
|
|
previewPlayer.buffer = track.buffer;
|
|
previewPlayer.connect(track.volumeNode);
|
|
|
|
track.previewPlayer = previewPlayer;
|
|
|
|
track.type = "sampler";
|
|
return track;
|
|
} catch (error) {
|
|
console.error("Erro ao carregar sample:", track.samplePath, error);
|
|
try { track.player?.dispose(); } catch {}
|
|
try { track.previewPlayer?.dispose(); } catch {}
|
|
try { track.buffer?.dispose?.(); } catch {}
|
|
track.player = null;
|
|
track.previewPlayer = null;
|
|
track.buffer = null;
|
|
return track;
|
|
}
|
|
}
|
|
|
|
export function addTrackToState() {
|
|
const totalSteps = getTotalSteps();
|
|
|
|
const all = appState.pattern.tracks || [];
|
|
const nonBass = all.filter(t => t.type !== "bassline");
|
|
|
|
// Prefere usar como referência um instrumento do rack raiz que já tenha patterns
|
|
const referenceTrack =
|
|
nonBass.find(t => t.parentBasslineId == null && Array.isArray(t.patterns) && t.patterns.length) ||
|
|
nonBass.find(t => Array.isArray(t.patterns) && t.patterns.length) ||
|
|
null;
|
|
|
|
// Quantos patterns existem no projeto? (pega o máximo entre instrumentos)
|
|
const patternCount = Math.max(
|
|
1,
|
|
...nonBass.map(t => Array.isArray(t.patterns) ? t.patterns.length : 0)
|
|
);
|
|
|
|
const patterns = Array.from({ length: patternCount }, (_, i) => {
|
|
const ref = referenceTrack?.patterns?.[i];
|
|
|
|
const refStepsLen = Array.isArray(ref?.steps) ? ref.steps.length : totalSteps;
|
|
const name = String(ref?.name || `Pattern ${i + 1}`);
|
|
const pos = Number.isFinite(Number(ref?.pos)) ? Number(ref.pos) : i * 192; // 1 bar = 192 ticks no LMMS
|
|
|
|
return {
|
|
name,
|
|
steps: new Array(refStepsLen).fill(false),
|
|
notes: [],
|
|
pos
|
|
};
|
|
});
|
|
|
|
const rootCount = nonBass.filter(t => t.parentBasslineId == null).length;
|
|
|
|
const newId = Date.now() + Math.random();
|
|
const newTrack = {
|
|
id: newId,
|
|
name: `Novo Instrumento ${rootCount + 1}`,
|
|
samplePath: null,
|
|
type: "plugin",
|
|
|
|
// ✅ Rack raiz
|
|
parentBasslineId: null,
|
|
|
|
// instrumento default
|
|
instrumentName: "kicker",
|
|
instrumentXml: DEFAULT_KICKER_XML,
|
|
|
|
player: null,
|
|
buffer: null,
|
|
instrument: null,
|
|
previewPlayer: null,
|
|
|
|
patterns,
|
|
activePatternIndex: appState.pattern.activePatternIndex ?? 0,
|
|
|
|
isMuted: false,
|
|
muted: false,
|
|
volume: DEFAULT_VOLUME,
|
|
pan: DEFAULT_PAN,
|
|
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
|
|
pannerNode: new Tone.Panner(DEFAULT_PAN),
|
|
};
|
|
|
|
newTrack.volumeNode.connect(newTrack.pannerNode);
|
|
newTrack.pannerNode.connect(getMainGainNode());
|
|
|
|
loadAudioForTrack(newTrack);
|
|
|
|
appState.pattern.tracks.push(newTrack);
|
|
appState.pattern.activeTrackId = newTrack.id;
|
|
|
|
console.log("✅ Instrumento adicionado no rack raiz:", newTrack.name);
|
|
}
|
|
|
|
export function removeTrackById(trackId) {
|
|
const index = appState.pattern.tracks.findIndex(t => t.id === trackId);
|
|
|
|
if (index !== -1) {
|
|
const trackToRemove = appState.pattern.tracks[index];
|
|
|
|
// Limpeza de memória
|
|
try { trackToRemove.player?.dispose(); } catch {}
|
|
try { trackToRemove.buffer?.dispose?.(); } catch {}
|
|
try { trackToRemove.instrument?.dispose(); } catch {}
|
|
try { trackToRemove.pannerNode?.disconnect(); } catch {}
|
|
try { trackToRemove.volumeNode?.disconnect(); } catch {}
|
|
try { trackToRemove.previewPlayer?.dispose(); } catch {}
|
|
|
|
// Remove do array
|
|
appState.pattern.tracks.splice(index, 1);
|
|
|
|
// Ajusta seleção se necessário
|
|
if (appState.pattern.activeTrackId === trackId) {
|
|
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
|
|
}
|
|
|
|
return true; // Retorna sucesso
|
|
}
|
|
return false; // Não achou (o fantasma não existe aqui)
|
|
}
|
|
|
|
export function removeLastTrackFromState() {
|
|
if (appState.pattern.tracks.length > 0) {
|
|
const trackToRemove = appState.pattern.tracks[appState.pattern.tracks.length - 1];
|
|
|
|
try { trackToRemove.player?.dispose(); } catch {}
|
|
try { trackToRemove.previewPlayer?.dispose(); } catch {}
|
|
try { trackToRemove.buffer?.dispose?.(); } catch {}
|
|
try { trackToRemove.instrument?.dispose(); } catch {}
|
|
try { trackToRemove.pannerNode?.disconnect(); } catch {}
|
|
try { trackToRemove.volumeNode?.disconnect(); } catch {}
|
|
|
|
appState.pattern.tracks.pop();
|
|
if (appState.pattern.activeTrackId === trackToRemove.id) {
|
|
appState.pattern.activeTrackId = appState.pattern.tracks[0]?.id ?? null;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
export async function updateTrackSample(trackIndex, samplePath) {
|
|
const track = appState.pattern.tracks[trackIndex];
|
|
if (track) {
|
|
track.samplePath = samplePath;
|
|
track.name = samplePath.split("/").pop();
|
|
|
|
// Reseta o tipo baseado no novo arquivo
|
|
const isAudio = /\.(wav|mp3|ogg|flac|m4a)$/i.test(samplePath);
|
|
track.type = isAudio ? 'sampler' : 'plugin';
|
|
|
|
await loadAudioForTrack(track);
|
|
}
|
|
}
|
|
|
|
export function toggleStepState(trackIndex, stepIndex) {
|
|
const track = appState.pattern.tracks[trackIndex];
|
|
if (track && track.patterns && track.patterns.length > 0) {
|
|
const activePattern = track.patterns[track.activePatternIndex];
|
|
if (activePattern && activePattern.steps.length > stepIndex) {
|
|
activePattern.steps[stepIndex] = !activePattern.steps[stepIndex];
|
|
}
|
|
}
|
|
} |