diff --git a/assets/css/creator.css b/assets/css/creator.css
index e7fbf043..4d104de5 100755
--- a/assets/css/creator.css
+++ b/assets/css/creator.css
@@ -278,6 +278,9 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
right: 0;
}
+.track-mute { background: #2ecc71; }
+.track-mute.muted { background: #e74c3c; }
+
/* =============================================== */
/* BEAT EDITOR / STEP SEQUENCER
/* =============================================== */
diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js
index 3897951d..da38db1b 100755
--- a/assets/js/creations/audio/audio_ui.js
+++ b/assets/js/creations/audio/audio_ui.js
@@ -456,7 +456,10 @@ export function renderAudioEditor() {
// pega os instrumentos que pertencem a esse rack (mesma lógica do pattern_ui) :contentReference[oaicite:1]{index=1}
const srcId = basslineTrack.instrumentSourceId || basslineTrack.id;
const children = (appState.pattern.tracks || []).filter(
- (t) => t.type !== "bassline" && t.parentBasslineId === srcId && !t.muted
+ (t) =>
+ t.type !== "bassline" &&
+ t.parentBasslineId === srcId &&
+ !(t.isMuted || t.muted)
);
let maxSteps = 0;
diff --git a/assets/js/creations/pattern/pattern_state.js b/assets/js/creations/pattern/pattern_state.js
index 7d7c95c7..ea01df6f 100755
--- a/assets/js/creations/pattern/pattern_state.js
+++ b/assets/js/creations/pattern/pattern_state.js
@@ -16,6 +16,82 @@ const DEFAULT_KICKER_XML = `
`;
+// ------------------------------------------------------------
+// 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 {}
@@ -30,33 +106,14 @@ export function initializePatternState() {
}
export async function loadAudioForTrack(track) {
- // 1) Garante Volume/Pan
+ // 1) Garante Volume/Pan/Mute e reconecta a cadeia base
try {
- if (!track.volumeNode) {
- track.volumeNode = new Tone.Volume(
- track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume)
- );
- } else {
- track.volumeNode.volume.value =
- track.volume === 0 ? -Infinity : Tone.gainToDb(track.volume);
- }
+ applyTrackMix(track);
- if (!track.pannerNode) {
- track.pannerNode = new Tone.Panner(track.pan ?? 0);
- } else {
- track.pannerNode.pan.value = track.pan ?? 0;
- }
-
- // Desconecta o que existir
+ // desconecta o que existir (para reconectar sem duplicar rotas)
try { track.instrument?.disconnect(); } catch {}
try { track.player?.disconnect(); } catch {}
try { track.previewPlayer?.disconnect(); } catch {}
-
- // Reconecta cadeia base
- try { track.volumeNode.disconnect(); } catch {}
- try { track.pannerNode.disconnect(); } catch {}
- track.volumeNode.connect(track.pannerNode);
- track.pannerNode.connect(getMainGainNode());
} catch (e) {
console.error("Erro ao criar nós de áudio base:", e);
}
@@ -257,6 +314,8 @@ export function addTrackToState() {
patterns,
activePatternIndex: appState.pattern.activePatternIndex ?? 0,
+ isMuted: false,
+ muted: false,
volume: DEFAULT_VOLUME,
pan: DEFAULT_PAN,
volumeNode: new Tone.Volume(Tone.gainToDb(DEFAULT_VOLUME)),
diff --git a/assets/js/creations/pattern/pattern_ui.js b/assets/js/creations/pattern/pattern_ui.js
index 16b8f1a8..d8e897e0 100755
--- a/assets/js/creations/pattern/pattern_ui.js
+++ b/assets/js/creations/pattern/pattern_ui.js
@@ -1,11 +1,83 @@
// js/pattern/pattern_ui.js
import { appState } from "../state.js";
-import { updateTrackSample } from "./pattern_state.js";
-import { playSample, stopPlayback } from "./pattern_audio.js";
+import {
+ updateTrackSample,
+ setPatternTrackMute,
+ setPatternTrackVolume,
+ setPatternTrackPan,
+} from "./pattern_state.js";
+import { playSample, stopPlayback } from "./pattern_audio.js";
import { getTotalSteps } from "../utils.js";
-import { sendAction } from '../socket.js';
-import { initializeAudioContext } from '../audio.js';
-import * as Tone from "https://esm.sh/tone";
+import { sendAction } from "../socket.js";
+import { initializeAudioContext } from "../audio.js";
+import * as Tone from "https://esm.sh/tone";
+
+// ------------------------------------------------------------
+// Knob helpers (drag vertical para ajustar)
+// ------------------------------------------------------------
+const _KNOB_MIN_DEG = -135;
+const _KNOB_MAX_DEG = 135;
+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 _valueToDeg(control, value) {
+ if (control === "pan") {
+ const v = _clamp(value, -1, 1);
+ const norm = (v + 1) / 2; // 0..1
+ return _KNOB_MIN_DEG + norm * (_KNOB_MAX_DEG - _KNOB_MIN_DEG);
+ }
+ const v = _clamp(value, 0, _MAX_TRACK_VOLUME);
+ const norm = v / _MAX_TRACK_VOLUME;
+ return _KNOB_MIN_DEG + norm * (_KNOB_MAX_DEG - _KNOB_MIN_DEG);
+}
+
+function _setKnobIndicator(knobEl, control, value) {
+ const ind = knobEl.querySelector(".knob-indicator");
+ if (!ind) return;
+ ind.style.transform = `rotate(${_valueToDeg(control, value)}deg)`;
+}
+
+function _attachKnobDrag(knobEl, { control, getCurrent, setLocal, commit }) {
+ if (!knobEl) return;
+
+ knobEl.addEventListener("mousedown", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ initializeAudioContext();
+
+ const startY = e.clientY;
+ const startVal = Number(getCurrent?.() ?? 0);
+ const sensitivity = control === "pan" ? 0.01 : 0.005;
+
+ let lastVal = startVal;
+
+ const onMove = (ev) => {
+ const delta = (startY - ev.clientY) * sensitivity;
+ let v = startVal + delta;
+
+ if (control === "pan") v = _clamp(v, -1, 1);
+ else v = _clamp(v, 0, _MAX_TRACK_VOLUME);
+
+ lastVal = v;
+ setLocal?.(v);
+ _setKnobIndicator(knobEl, control, v);
+ };
+
+ const onUp = () => {
+ window.removeEventListener("mousemove", onMove);
+ window.removeEventListener("mouseup", onUp);
+ commit?.(lastVal);
+ };
+
+ window.addEventListener("mousemove", onMove);
+ window.addEventListener("mouseup", onUp);
+ });
+}
// Função principal de renderização para o editor de patterns
export function renderPatternEditor() {
@@ -140,6 +212,71 @@ export function renderPatternEditor() {
`;
+ // --------------------------
+ // Mute (ativar/desativar canal)
+ // --------------------------
+ const muteBtn = trackLane.querySelector(".track-mute");
+ if (muteBtn) {
+ const isMutedNow = !!(trackData.isMuted || trackData.muted);
+ muteBtn.classList.toggle("muted", isMutedNow);
+ muteBtn.title = isMutedNow ? "Unmute" : "Mute";
+
+ muteBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ initializeAudioContext();
+
+ const t = appState.pattern.tracks[originalIndex];
+ const cur = !!(t?.isMuted || t?.muted);
+ const next = !cur;
+
+ // aplica local (instantâneo)
+ setPatternTrackMute(trackData.id, next);
+
+ // feedback visual
+ muteBtn.classList.toggle("muted", next);
+ muteBtn.title = next ? "Unmute" : "Mute";
+
+ // broadcast (colaboração)
+ sendAction({
+ type: "SET_PATTERN_TRACK_MUTE",
+ trackId: trackData.id,
+ isMuted: next,
+ });
+ });
+ }
+
+ // --------------------------
+ // Knobs (VOL / PAN)
+ // --------------------------
+ trackLane.querySelectorAll(".knob").forEach((knobEl) => {
+ const control = knobEl.dataset.control;
+ const trackId = knobEl.dataset.trackId;
+
+ const t = appState.pattern.tracks[originalIndex];
+ const initial = control === "pan" ? (t?.pan ?? 0) : (t?.volume ?? 1);
+ _setKnobIndicator(knobEl, control, initial);
+
+ _attachKnobDrag(knobEl, {
+ control,
+ getCurrent: () => {
+ const tr = appState.pattern.tracks.find((x) => String(x.id) === String(trackId));
+ return control === "pan" ? (tr?.pan ?? 0) : (tr?.volume ?? 1);
+ },
+ setLocal: (v) => {
+ if (control === "pan") setPatternTrackPan(trackId, v);
+ else setPatternTrackVolume(trackId, v);
+ },
+ commit: (v) => {
+ if (control === "pan") {
+ sendAction({ type: "SET_PATTERN_TRACK_PAN", trackId, pan: v });
+ } else {
+ sendAction({ type: "SET_PATTERN_TRACK_VOLUME", trackId, volume: v });
+ }
+ },
+ });
+ });
+
// Eventos de Seleção
trackLane.addEventListener('click', () => {
if (appState.pattern.activeTrackId === trackData.id) return;
diff --git a/assets/js/creations/socket.js b/assets/js/creations/socket.js
index bb37776d..f71dd67d 100755
--- a/assets/js/creations/socket.js
+++ b/assets/js/creations/socket.js
@@ -9,6 +9,9 @@ import {
removeLastTrackFromState,
updateTrackSample,
removeTrackById,
+ setPatternTrackMute,
+ setPatternTrackVolume,
+ setPatternTrackPan,
} from "./pattern/pattern_state.js";
import {
addAudioTrackLane,
@@ -1352,6 +1355,33 @@ async function handleActionBroadcast(action) {
break;
}
+ case "SET_PATTERN_TRACK_MUTE": {
+ const { trackId, isMuted } = action;
+ setPatternTrackMute(trackId, !!isMuted);
+ renderAll();
+ saveStateToSession();
+ restartAudioEditorIfPlaying?.();
+ break;
+ }
+
+ case "SET_PATTERN_TRACK_VOLUME": {
+ const { trackId, volume } = action;
+ setPatternTrackVolume(trackId, volume);
+ renderAll();
+ saveStateToSession();
+ restartAudioEditorIfPlaying?.();
+ break;
+ }
+
+ case "SET_PATTERN_TRACK_PAN": {
+ const { trackId, pan } = action;
+ setPatternTrackPan(trackId, pan);
+ renderAll();
+ saveStateToSession();
+ restartAudioEditorIfPlaying?.();
+ break;
+ }
+
case "SET_ACTIVE_PATTERN": {
// índice que veio do seletor global
const { patternIndex } = action;