diff --git a/style.css b/style.css
index b145534..bc4de92 100644
--- a/style.css
+++ b/style.css
@@ -1215,13 +1215,40 @@ input[type=range] {
}
.track-headers {
- width: 58px;
+ width: 64px;
flex: none;
border-right: 1px solid var(--border);
- padding-top: 26px;
+ padding-top: 0;
background: var(--panel);
z-index: 2;
overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.track-head-tools {
+ height: 26px;
+ flex: none;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 2px;
+ border-bottom: 1px solid var(--border);
+ box-sizing: border-box;
+}
+
+.track-head-tools .btn {
+ padding: 1px 4px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0;
+ line-height: 1.2;
+ min-width: 0;
+}
+
+#trackHeadInner {
+ flex: 1;
+ min-height: 0;
}
.track-head {
From cc4943b3299dc4ee00cf92cc3074ef5b7381a525 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 23 Jul 2026 12:12:38 +0300
Subject: [PATCH 03/11] feat: add context menu for track removal
---
CLAUDE.md | 2 ++
README.md | 3 ++-
app.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
index.html | 1 +
style.css | 35 ++++++++++++++++++++++++
5 files changed, 118 insertions(+), 1 deletion(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 650d30c..cf375cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -337,6 +337,8 @@ glitch (RGB split + jitter) · pop (overshoot scale — stickers/captions).
- Rendering order: V1 is drawn first, then V2, V3, … on top. SVG/image/text clips
go on any V track (higher V lanes are handy overlay lanes). Add lanes with the
timeline **+V** / **+A** buttons (or by listing them in `tracks`); up to 16 per kind.
+ Right-click an empty track header → **Remove track** (disabled if the lane has
+ clips or is the last video/audio track).
- **Audio tracks A1–An** hold both standalone audio files *and* linked companions
for imported video. Dropping a video creates the picture on a V track
(`props.volume: 0` so it isn't doubled) plus one `kind:"audio"` clip per source
diff --git a/README.md b/README.md
index 3111b5a..b350223 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,8 @@ same time.
**Editing**
- Video + audio tracks (default 3+4; add more with **+V** / **+A** in the track
- header), drag/trim/split/snap, undo/redo
+ header; right-click an empty header → **Remove track**), drag/trim/split/snap,
+ undo/redo
- **Settings** (cog in the top bar) — optional prefs stored in this browser via
`localStorage`. Enable **Link timeline and Project bin selection** so picking a
timeline clip highlights its media in Project, and clicking a Project item
diff --git a/app.js b/app.js
index a36e988..87e6c6e 100644
--- a/app.js
+++ b/app.js
@@ -239,6 +239,79 @@ function ensureAudioTrackCount(need) {
}
return added;
}
+function trackHasClips(trackId) {
+ return project.clips.some((c) => c.track === trackId);
+}
+function canRemoveTrack(trackId) {
+ const t = TRACKS.find((x) => x.id === trackId);
+ if (!t) return { ok: false, reason: "Unknown track" };
+ if (trackHasClips(trackId)) return { ok: false, reason: "Track has clips" };
+ if (TRACKS.filter((x) => x.kind === t.kind).length <= 1)
+ return { ok: false, reason: `Keep at least one ${t.kind} track` };
+ return { ok: true, reason: "" };
+}
+function removeTimelineTrack(trackId) {
+ const check = canRemoveTrack(trackId);
+ if (!check.ok) { toast(check.reason); return false; }
+ const wasAudio = TRACKS.find((t) => t.id === trackId)?.kind === "audio";
+ const idx = TRACKS.findIndex((t) => t.id === trackId);
+ if (idx < 0) return false;
+ TRACKS.splice(idx, 1);
+ syncTrackIds();
+ if (state.disabledTracks.has(trackId)) {
+ state.disabledTracks.delete(trackId);
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ project.tracks = serializeTracks();
+ if (wasAudio) syncAudioGraphTracks();
+ buildTrackDOM();
+ syncAllTrackDisabledUI();
+ state.dirtyTimeline = true;
+ rebuildClips();
+ scheduleSave();
+ return true;
+}
+/* Lightweight right-click menu for track headers. */
+let trackCtxMenu = null;
+function hideTrackCtxMenu() {
+ if (trackCtxMenu) { trackCtxMenu.remove(); trackCtxMenu = null; }
+}
+function showTrackCtxMenu(clientX, clientY, track) {
+ hideTrackCtxMenu();
+ const check = canRemoveTrack(track.id);
+ const menu = document.createElement("div");
+ menu.className = "ctx-menu";
+ menu.id = "trackCtxMenu";
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "ctx-item";
+ btn.textContent = "Remove track";
+ if (!check.ok) {
+ btn.disabled = true;
+ btn.title = check.reason;
+ } else {
+ btn.addEventListener("click", () => {
+ hideTrackCtxMenu();
+ removeTimelineTrack(track.id);
+ });
+ }
+ menu.appendChild(btn);
+ document.body.appendChild(menu);
+ trackCtxMenu = menu;
+ const pad = 6;
+ const w = menu.offsetWidth, h = menu.offsetHeight;
+ let x = clientX, y = clientY;
+ if (x + w + pad > window.innerWidth) x = window.innerWidth - w - pad;
+ if (y + h + pad > window.innerHeight) y = window.innerHeight - h - pad;
+ menu.style.left = Math.max(pad, x) + "px";
+ menu.style.top = Math.max(pad, y) + "px";
+}
+document.addEventListener("pointerdown", (e) => {
+ if (trackCtxMenu && !trackCtxMenu.contains(e.target)) hideTrackCtxMenu();
+}, true);
+document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape") hideTrackCtxMenu();
+}, true);
/* ── User settings (localStorage; optional behavior toggles) ── */
const SETTINGS_KEY = "fablecut-settings";
@@ -2099,6 +2172,11 @@ function buildTrackDOM() {
ev.stopPropagation();
toggleTrackEnabled(t.id);
});
+ h.addEventListener("contextmenu", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ showTrackCtxMenu(ev.clientX, ev.clientY, t);
+ });
inner.appendChild(h);
const row = document.createElement("div");
row.className = "track" + (on ? "" : " disabled");
diff --git a/index.html b/index.html
index ae24deb..8b36704 100644
--- a/index.html
+++ b/index.html
@@ -218,6 +218,7 @@
Keyboard shortcuts
| Alt+T | Add transition at playhead (in / out by half) |
| Del | Delete selected clip(s), or clear focused transition |
| Drag empty area | Marquee-select clips |
+
| Right-click track | Remove empty track (keeps ≥1 video + ≥1 audio) |
| Ctrl+click bin | Import media files |
| Ctrl+click clip | Add / remove from selection |
| Ctrl+A / Esc | Select all / deselect |
diff --git a/style.css b/style.css
index bc4de92..45534c9 100644
--- a/style.css
+++ b/style.css
@@ -1267,6 +1267,41 @@ input[type=range] {
opacity: .55;
}
+.ctx-menu {
+ position: fixed;
+ z-index: 10000;
+ min-width: 140px;
+ padding: 4px;
+ background: var(--panel, #1c1c22);
+ border: 1px solid var(--border, #2e2e36);
+ border-radius: 6px;
+ box-shadow: 0 8px 24px #00000088;
+}
+
+.ctx-menu .ctx-item {
+ display: block;
+ width: 100%;
+ margin: 0;
+ padding: 6px 10px;
+ border: 0;
+ border-radius: 4px;
+ background: transparent;
+ color: #e8e8ec;
+ font: inherit;
+ font-size: 12px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.ctx-menu .ctx-item:hover:not(:disabled) {
+ background: #ffffff14;
+}
+
+.ctx-menu .ctx-item:disabled {
+ opacity: .45;
+ cursor: default;
+}
+
.track-toggle {
display: inline-flex;
align-items: center;
From da81c6d697eef4904243ad13e39e62eba1507de9 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 23 Jul 2026 12:21:04 +0300
Subject: [PATCH 04/11] feat: add 'solo' track button, update README.md
---
README.md | 6 +++++
app.js | 65 +++++++++++++++++++++++++++++++++++++++++++++++++-----
index.html | 1 +
style.css | 48 +++++++++++++++++++++++++++++++++++++---
4 files changed, 112 insertions(+), 8 deletions(-)
diff --git a/README.md b/README.md
index b350223..7e81295 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,12 @@ same time.
`localStorage`. Enable **Link timeline and Project bin selection** so picking a
timeline clip highlights its media in Project, and clicking a Project item
selects every timeline clip that uses it (off by default).
+- Video + audio tracks (default 3+4), drag/trim/split/snap, undo/redo
+ - **+V** / **+A** in the track header add a video or audio lane (up to 16 each)
+ - Right-click an empty track header → **Remove track** (disabled if the lane
+ has clips, or if it would leave you with zero video/audio tracks)
+ - Track header **S** solos that lane (mutes all others); click again to restore
+ the previous mute state. Using the mute toggle while soloed exits solo.
- **Direct manipulation on the monitor** — click a clip or title on the preview to
move, resize (corner handles), or rotate (top handle, Shift-snap) it directly
- **Timeline multi-select** — rubber-band marquee (drag on empty track area),
diff --git a/app.js b/app.js
index 87e6c6e..4896dda 100644
--- a/app.js
+++ b/app.js
@@ -207,6 +207,10 @@ function addTimelineTrack(kind) {
sortTracksInPlace();
applyTrackHeights();
project.tracks = serializeTracks();
+ if (state.soloId && state.soloId !== id) {
+ state.disabledTracks.add(id);
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
if (kind === "audio") syncAudioGraphTracks();
buildTrackDOM();
syncAllTrackDisabledUI();
@@ -256,12 +260,16 @@ function removeTimelineTrack(trackId) {
const wasAudio = TRACKS.find((t) => t.id === trackId)?.kind === "audio";
const idx = TRACKS.findIndex((t) => t.id === trackId);
if (idx < 0) return false;
+ if (state.soloId === trackId) clearTrackSolo({ restore: true });
TRACKS.splice(idx, 1);
syncTrackIds();
if (state.disabledTracks.has(trackId)) {
state.disabledTracks.delete(trackId);
project.disabledTracks = [...state.disabledTracks].sort();
}
+ if (Array.isArray(state.soloRestore)) {
+ state.soloRestore = state.soloRestore.filter((id) => id !== trackId);
+ }
project.tracks = serializeTracks();
if (wasAudio) syncAudioGraphTracks();
buildTrackDOM();
@@ -375,6 +383,8 @@ const state = {
workAreaPlay: false, // when true, play + Home/End stay inside IN/OUT
binTab: "project", // project | elements | sfx | svg
disabledTracks: new Set(), // mirror of project.disabledTracks for fast lookup
+ soloId: null, // track id when solo is active, else null
+ soloRestore: null, // disabledTracks snapshot taken when solo engaged
transFocus: null, // "in" | "out" — inspector transition row highlighted
kfGraphs: new Set(), // animatable prop keys with open monitor graphs
};
@@ -388,27 +398,62 @@ function isTrackEnabled(id) {
}
function syncTrackDisabledUI(id) {
const on = isTrackEnabled(id);
+ const solo = state.soloId === id;
const head = els.trackHeaders.querySelector(`.track-head[data-track="${id}"]`);
const row = els.tracks.querySelector(`.track[data-track="${id}"]`);
if (head) {
head.classList.toggle("disabled", !on);
+ head.classList.toggle("solo", solo);
const btn = head.querySelector(".track-toggle");
if (btn) {
btn.setAttribute("aria-pressed", on ? "true" : "false");
btn.title = on ? "Disable track" : "Enable track";
}
+ const sBtn = head.querySelector(".track-solo");
+ if (sBtn) {
+ sBtn.setAttribute("aria-pressed", solo ? "true" : "false");
+ sBtn.classList.toggle("on", solo);
+ sBtn.title = solo ? "Unsolo track" : "Solo track (mute all others)";
+ }
+ }
+ if (row) {
+ row.classList.toggle("disabled", !on);
+ row.classList.toggle("solo", solo);
}
- if (row) row.classList.toggle("disabled", !on);
}
function syncAllTrackDisabledUI() {
for (const t of TRACKS) syncTrackDisabledUI(t.id);
}
+function clearTrackSolo({ restore = false } = {}) {
+ if (!state.soloId) return;
+ if (restore && Array.isArray(state.soloRestore)) {
+ state.disabledTracks = new Set(state.soloRestore.filter((id) => TRACK_IDS.has(id)));
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ state.soloId = null;
+ state.soloRestore = null;
+}
+function toggleTrackSolo(id) {
+ if (!TRACK_IDS.has(id)) return;
+ if (state.soloId === id) {
+ clearTrackSolo({ restore: true });
+ } else {
+ if (!state.soloId) state.soloRestore = [...state.disabledTracks];
+ state.soloId = id;
+ state.disabledTracks = new Set(TRACKS.filter((t) => t.id !== id).map((t) => t.id));
+ project.disabledTracks = [...state.disabledTracks].sort();
+ }
+ syncAllTrackDisabledUI();
+ scheduleSave();
+}
function toggleTrackEnabled(id) {
if (!TRACK_IDS.has(id)) return;
+ // Manual mute exits solo without restoring the pre-solo snapshot
+ if (state.soloId) clearTrackSolo({ restore: false });
if (state.disabledTracks.has(id)) state.disabledTracks.delete(id);
else state.disabledTracks.add(id);
project.disabledTracks = [...state.disabledTracks].sort();
- syncTrackDisabledUI(id);
+ syncAllTrackDisabledUI();
scheduleSave();
}
const runtime = {
@@ -717,6 +762,8 @@ function applyProject(data) {
if (m.folderId && !folderIds.has(m.folderId)) m.folderId = null;
}
state.disabledTracks = new Set(disabledTracks);
+ state.soloId = null;
+ state.soloRestore = null;
for (const c of project.clips) {
c.props = { ...DEFAULT_PROPS, ...(c.props || {}) };
if (c.keyframes) for (const arr of Object.values(c.keyframes))
@@ -2158,20 +2205,28 @@ function buildTrackDOM() {
els.tracks.innerHTML = "";
for (const t of TRACKS) {
const on = isTrackEnabled(t.id);
+ const solo = state.soloId === t.id;
const h = document.createElement("div");
- h.className = "track-head" + (on ? "" : " disabled");
+ h.className = "track-head" + (on ? "" : " disabled") + (solo ? " solo" : "");
h.dataset.track = t.id;
h.style.height = t.h + "px";
h.innerHTML =
`
` +
- `
${t.id}`;
+ `
${t.id}` +
+ `
`;
h.querySelector(".track-toggle").addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
toggleTrackEnabled(t.id);
});
+ h.querySelector(".track-solo").addEventListener("click", (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+ toggleTrackSolo(t.id);
+ });
h.addEventListener("contextmenu", (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -2179,7 +2234,7 @@ function buildTrackDOM() {
});
inner.appendChild(h);
const row = document.createElement("div");
- row.className = "track" + (on ? "" : " disabled");
+ row.className = "track" + (on ? "" : " disabled") + (solo ? " solo" : "");
row.dataset.track = t.id;
row.style.height = t.h + "px";
els.tracks.appendChild(row);
diff --git a/index.html b/index.html
index 8b36704..4ea4393 100644
--- a/index.html
+++ b/index.html
@@ -219,6 +219,7 @@
Keyboard shortcuts
| Del | Delete selected clip(s), or clear focused transition |
| Drag empty area | Marquee-select clips |
| Right-click track | Remove empty track (keeps ≥1 video + ≥1 audio) |
+
| Track S | Solo track (mute all others; click again to restore) |
| Ctrl+click bin | Import media files |
| Ctrl+click clip | Add / remove from selection |
| Ctrl+A / Esc | Select all / deselect |
diff --git a/style.css b/style.css
index 45534c9..678ed8c 100644
--- a/style.css
+++ b/style.css
@@ -1215,7 +1215,7 @@ input[type=range] {
}
.track-headers {
- width: 64px;
+ width: 68px;
flex: none;
border-right: 1px solid var(--border);
padding-top: 0;
@@ -1254,8 +1254,8 @@ input[type=range] {
.track-head {
display: flex;
align-items: center;
- gap: 4px;
- padding: 0 6px;
+ gap: 2px;
+ padding: 0 4px;
border-bottom: 1px solid #232328;
font-size: 11px;
font-weight: 700;
@@ -1263,10 +1263,52 @@ input[type=range] {
letter-spacing: .5px;
}
+.track-head .track-id {
+ flex: 1;
+ min-width: 0;
+ max-width: 3ch;
+}
+
+.track-solo {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: none;
+ width: 16px;
+ height: 16px;
+ margin: 0;
+ padding: 0;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: var(--dim);
+ font: inherit;
+ font-size: 9px;
+ font-weight: 800;
+ letter-spacing: 0;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.track-solo:hover {
+ color: #fff;
+ background: #ffffff12;
+}
+
+.track-solo.on {
+ color: #111;
+ background: #ffd166;
+ border-color: #ffd166;
+}
+
.track-head.disabled {
opacity: .55;
}
+.track-head.solo:not(.disabled) {
+ opacity: 1;
+}
+
.ctx-menu {
position: fixed;
z-index: 10000;
From de77b50da7461d19bdd03320f511aec3fb23093f Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 23 Jul 2026 15:45:02 +0300
Subject: [PATCH 05/11] fix: fix CodeRabbit findings
---
app.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/app.js b/app.js
index 4896dda..0f08679 100644
--- a/app.js
+++ b/app.js
@@ -175,6 +175,7 @@ function ensureTracksCoverClips() {
if (!c.track || TRACK_IDS.has(c.track)) continue;
const kind = c.kind === "audio" || /^A\d+$/i.test(c.track) ? "audio" : "video";
TRACKS.push(makeTrack(c.track, kind));
+ TRACK_IDS.add(c.track); // mark present before later clips (avoids duplicate makeTrack)
added = true;
}
if (added) {
@@ -2214,7 +2215,7 @@ function buildTrackDOM() {
`
` +
- `
${t.id}` +
+ `
${escapeHtml(t.id)}` +
`
`;
h.querySelector(".track-toggle").addEventListener("click", (ev) => {
From 6aba6a752076e3dde84de0e7941715825846fe67 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Fri, 24 Jul 2026 17:50:18 +0300
Subject: [PATCH 06/11] feat: add audio tracks automatically when dropping
media to a timeline
---
README.md | 3 +++
app.js | 35 +++++++++++++++++++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/README.md b/README.md
index 7e81295..ff16ed8 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,9 @@ same time.
has clips, or if it would leave you with zero video/audio tracks)
- Track header **S** solos that lane (mutes all others); click again to restore
the previous mute state. Using the mute toggle while soloed exits solo.
+ - Dropping a video with more audio channels than A-tracks **adds the missing
+ lanes automatically** (up to 16) and toasts how many were added; each channel
+ becomes a linked stem on its own A-track
- **Direct manipulation on the monitor** — click a clip or title on the preview to
move, resize (corner handles), or rotate (top handle, Shift-snap) it directly
- **Timeline multi-select** — rubber-band marquee (drag on empty track area),
diff --git a/app.js b/app.js
index 0f08679..075d0a5 100644
--- a/app.js
+++ b/app.js
@@ -1461,6 +1461,34 @@ function linkedAudioChannelCount(m) {
const n = Math.max(1, m.channels | 0);
return Math.min(n, audioTrackIds().length);
}
+/** Grow A-tracks to at least `need` (capped at MAX_TRACKS_PER_KIND). Returns how many were added. */
+function ensureAudioTrackCount(need) {
+ need = Math.min(Math.max(0, need | 0), MAX_TRACKS_PER_KIND);
+ let added = 0;
+ while (audioTrackIds().length < need) {
+ if (TRACKS.filter((t) => t.kind === "audio").length >= MAX_TRACKS_PER_KIND) break;
+ const id = nextTrackId("audio");
+ TRACKS.push(makeTrack(id, "audio"));
+ if (state.soloId && state.soloId !== id) state.disabledTracks.add(id);
+ added++;
+ }
+ if (!added) return 0;
+ sortTracksInPlace();
+ applyTrackHeights();
+ project.tracks = serializeTracks();
+ if (state.disabledTracks.size) project.disabledTracks = [...state.disabledTracks].sort();
+ syncAudioGraphTracks();
+ buildTrackDOM();
+ syncAllTrackDisabledUI();
+ state.dirtyTimeline = true;
+ rebuildClips();
+ const h = setTimelineHeight(Math.max(
+ $("timelinePanel")?.getBoundingClientRect().height || 0,
+ defaultTimelineHeight()
+ ));
+ localStorage.setItem(TL_H_KEY, String(h));
+ return added;
+}
/** Attach one audio clip per source channel (A1…An), sharing the video's linkGroup. */
function attachLinkedAudioChannels(videoClip, m, nCh) {
if (!videoClip?.linkGroup || !getClip(videoClip.id)) return [];
@@ -1522,6 +1550,13 @@ function addClipFromMedia(m, trackId, at) {
const finish = (nCh) => {
if (!getClip(c.id) || !c.linkGroup) return;
m.channels = nCh;
+ const want = Math.min(Math.max(1, nCh | 0), MAX_TRACKS_PER_KIND);
+ const added = ensureAudioTrackCount(want);
+ if (added) {
+ toast(added === 1
+ ? `Added an audio track for ${nCh}-channel audio`
+ : `Added ${added} audio tracks for ${nCh}-channel audio`);
+ }
attachLinkedAudioChannels(c, m, linkedAudioChannelCount(m));
state.dirtyTimeline = true;
scheduleSave();
From fc53e010b38654d05d4239b0914fac98309d3c73 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Fri, 24 Jul 2026 18:03:32 +0300
Subject: [PATCH 07/11] feat: minor fixes
---
app.js | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/app.js b/app.js
index 075d0a5..cb89878 100644
--- a/app.js
+++ b/app.js
@@ -3709,27 +3709,32 @@ function syncAudioGraphTracks() {
const audio = runtime.audio;
if (!audio) return;
const ids = audioTrackIds();
+ const idSet = new Set(ids);
+ // Drop buses for removed A-tracks (independent of meter state).
+ for (const id of Object.keys(audio.trackBus)) {
+ if (idSet.has(id)) continue;
+ try { audio.trackBus[id].disconnect(); } catch { }
+ delete audio.trackBus[id];
+ }
for (const id of ids) {
- if (!audio.trackBus[id]) {
- const g = audio.ctx.createGain();
- audio.trackBus[id] = g;
- g.connect(audio.master);
- }
+ if (!audio.trackBus[id]) audio.trackBus[id] = audio.ctx.createGain();
}
audio.audioTrackIds = ids.slice();
// Tear down meter so installMeterWorklet can rebuild with the new input count.
if (audio.meter) {
try { audio.meter.disconnect(); } catch { }
- for (const id of Object.keys(audio.trackBus)) {
- try { audio.trackBus[id].disconnect(); } catch { }
- if (ids.includes(id)) audio.trackBus[id].connect(audio.master);
- }
try { audio.master.disconnect(); } catch { }
audio.master.connect(audio.ctx.destination);
audio.master.connect(audio.recDest);
audio.meter = null;
audio.meterReady = false;
}
+ // Always wire current buses → master so re-added tracks stay audible if meter install fails.
+ for (const id of ids) {
+ const g = audio.trackBus[id];
+ try { g.disconnect(); } catch { }
+ try { g.connect(audio.master); } catch { }
+ }
installMeterWorklet(audio).catch(() => {});
// Re-route clip gains onto (possibly new) buses
for (const c of project.clips) {
From f2d3eb341f64b7beb1cf2828ec4d1f17fd84bbb3 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Tue, 28 Jul 2026 12:30:41 +0300
Subject: [PATCH 08/11] fix: fix CodeRabbit findings
---
app.js | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/app.js b/app.js
index cb89878..7f91782 100644
--- a/app.js
+++ b/app.js
@@ -160,9 +160,15 @@ function sortTracksInPlace() {
syncTrackIds();
}
function applyTracksFromProject(defs) {
- const list = Array.isArray(defs) && defs.length
+ const raw = Array.isArray(defs) && defs.length
? defs.filter((d) => d && d.id && (d.kind === "video" || d.kind === "audio"))
: DEFAULT_TRACK_DEFS;
+ const seen = new Set();
+ const list = raw.filter((d) => {
+ if (seen.has(d.id)) return false;
+ seen.add(d.id);
+ return true;
+ });
TRACKS.length = 0;
for (const d of list) TRACKS.push(makeTrack(d.id, d.kind === "audio" ? "audio" : "video"));
sortTracksInPlace();
@@ -314,6 +320,7 @@ function showTrackCtxMenu(clientX, clientY, track) {
if (y + h + pad > window.innerHeight) y = window.innerHeight - h - pad;
menu.style.left = Math.max(pad, x) + "px";
menu.style.top = Math.max(pad, y) + "px";
+ btn.focus();
}
document.addEventListener("pointerdown", (e) => {
if (trackCtxMenu && !trackCtxMenu.contains(e.target)) hideTrackCtxMenu();
@@ -1487,6 +1494,7 @@ function ensureAudioTrackCount(need) {
defaultTimelineHeight()
));
localStorage.setItem(TL_H_KEY, String(h));
+ scheduleSave();
return added;
}
/** Attach one audio clip per source channel (A1…An), sharing the video's linkGroup. */
@@ -1494,6 +1502,8 @@ function attachLinkedAudioChannels(videoClip, m, nCh) {
if (!videoClip?.linkGroup || !getClip(videoClip.id)) return [];
const lg = videoClip.linkGroup;
// Drop any prior stems for this group (e.g. stereo placeholder → 3.0 upgrade).
+ const doomed = project.clips.filter((x) => x.linkGroup === lg && x.kind === "audio");
+ for (const c of doomed) releaseClipEl(c.id);
project.clips = project.clips.filter((x) => !(x.linkGroup === lg && x.kind === "audio"));
const ids = audioTrackIds();
const n = Math.min(Math.max(1, nCh | 0), ids.length);
@@ -1626,7 +1636,10 @@ async function reconcileAudioChannels(videoClip) {
});
added++;
}
- if (!added && !removed) return;
+ if (!added && !removed) {
+ if (newTracks > 0) scheduleSave();
+ return;
+ }
state.dirtyTimeline = true;
scheduleSave(); renderInspector();
if (chCount > ids.length)
From 1b66953ba35e91ec6aba7fffcba31cc9055fd269 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 30 Jul 2026 12:05:37 +0300
Subject: [PATCH 09/11] feat: add audio pan to audio clips
Assuming a stereo downmix, each audio clip has a new property 'Pan', this allows to build a proper audio in the final output
---
CHANGELOG.md | 13 +++++
CLAUDE.md | 11 ++--
app.js | 135 ++++++++++++++++++++++++++++++++++++--------------
mcp-server.js | 12 ++++-
4 files changed, 130 insertions(+), 41 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 803246a..8e62413 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,8 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- Per-clip **stereo pan** (`props.pan`, −1…+1) on video/audio clips — inspector
+ slider + keyframes; preview, audio-hold, and Fast export all honor it. Linked
+ stems default to L `−1` / R `+1` / center for other channels; projects saved
+ before pan migrate once via `panSchema` (so omitting `pan: 0` later does not
+ re-hard-pan a centered stem). Compact MCP keeps `pan` on linked stems.
- Preview playback speed — a monitor toolbar toggle plus **J**/**K**/**L** shortcuts cycle the preview player through 1×, 1.5×, 2×, and 4× (L faster, J slower, K play/pause and reset to 1×). It rides on top of each clip's own speed and is forced back to 1× during export, so renders always come out at real time.
+### Fixed
+- Audio graph teardown on project reload — clip chains
+ (`MediaElementSource → splitter → gain → panner → bus`) are now fully
+ disconnected via `releaseClipEl` instead of only clearing the maps (which
+ left nodes wired to live track buses). Panner attach degrades gracefully if
+ `StereoPannerNode` is unavailable; inspector volume/pan changes refresh
+ audio-hold voices.
+
## [1.6.0] - 2026-07-14
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index cf375cd..a38f0bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,6 +157,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
"width": 1280, "height": 720, "fps": 30, // canvas/export settings
"background": "#000000", // canvas color behind all clips (optional)
"revision": 7, // bump on every write!
+ "panSchema": 1, // 1 = pan-aware; omit only on pre-pan projects (UI migrates once)
"markers": [ { "t": 2.5 }, { "t": 5.0, "label": "drop" } ],
// ^ beat/cue markers: gold diamonds on the ruler, snap targets for clip edges.
"inPoint": 10.023, // in timeline marker; paired with outPoint sets the focus on the part of the timeline
@@ -259,6 +260,7 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`,
| prop | default | notes |
|---|---|---|
| `volume` | 1 | 0–2 |
+| `pan` | 0 | −1 (full left) … 0 (center) … +1 (full right). Linked stems default L `−1` / R `+1` / other `0`. Projects saved before pan migrate once on load (`panSchema`); keep `panSchema: 1` (and explicit `pan` on stems) when rewriting the document so a centered stem is not re-hard-panned. |
| `speed` | 1 | 0.25–4× playback rate. **Keyframable → speed ramps**: with `keyframes.speed` the engine time-remaps (media time = `in` + ∫speed dt), in preview and in the export audio mix. Static case: source window consumed = `duration × speed`, so `in + duration×speed ≤ media.duration`. |
**Text clips only:**
@@ -324,7 +326,7 @@ footage. Example: 0.3 s impact shake over everything =
`{kind:"adjust", track:"V3", duration:0.3, props:{shake:18}}`.
**Animatable props** (usable in `keyframes`): x, y, scale, rotation, opacity,
-volume, speed, brightness, contrast, saturation, hue, blur, grayscale, sepia,
+volume, pan, speed, brightness, contrast, saturation, hue, blur, grayscale, sepia,
invert, temperature, tint, vignette, cornerRadius, shake, rgbSplit, grain,
fontSize, letterSpacing, glow.
@@ -342,8 +344,9 @@ glitch (RGB split + jitter) · pop (overshoot scale — stickers/captions).
- **Audio tracks A1–An** hold both standalone audio files *and* linked companions
for imported video. Dropping a video creates the picture on a V track
(`props.volume: 0` so it isn't doubled) plus one `kind:"audio"` clip per source
- channel (L/R/C/…) on consecutive A-tracks that share a `linkGroup` (and the same
- `mediaId` / timing). Standalone music/SFX also live on A-tracks. Linked partners
+ channel on consecutive A-tracks that share a `linkGroup` (and the same
+ `mediaId` / timing). Each stem is mono-isolated then placed with `pan` (defaults
+ preserve stereo: L `−1`, R `+1`). Standalone music/SFX also live on A-tracks. Linked partners
move/trim/split together — edit timing on any member of the group; do not treat
A-tracks as music-only.
- Media is `fit`-ted to the canvas (default "contain"), then crop → scale/x/y/
@@ -438,6 +441,8 @@ of previous durations.
**Music bed**: audio file on A1, `props.volume: 0.3`, trim with `in`/`duration`.
+**Center a mono stem**: linked or standalone audio clip with `props.pan: 0` (inspector **Pan** slider, −1…+1). Stereo video stems default to L `−1` / R `+1`; set A1 to `0` to center that channel in the mix.
+
**Cinematic grade**: `props.filterPreset: "teal-orange"` (tweak with
`temperature`/`vignette` on top).
diff --git a/app.js b/app.js
index 7f91782..557a400 100644
--- a/app.js
+++ b/app.js
@@ -47,7 +47,7 @@ const ZOOM_MAX = 300;
const TIMELINE_PAD_SEC = 15; // trailing empty seconds in the scrollable content
const DEFAULT_PROPS = {
- x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1,
+ x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0,
speed: 1, // playback rate (video/audio)
brightness: 100, contrast: 100, saturation: 100, hue: 0,
blur: 0, grayscale: 0, sepia: 0, invert: 0,
@@ -73,7 +73,7 @@ const DEFAULT_PROPS = {
boxFit: false, // false = wrap at fixed fontSize; true = scale font to fit box
vAlign: "middle", // top | middle | bottom — vertical align of the text block in the box
};
-const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "speed",
+const ANIMATABLE = ["x", "y", "scale", "rotation", "opacity", "volume", "pan", "speed",
"brightness", "contrast", "saturation", "hue", "blur", "grayscale", "sepia", "invert",
"temperature", "tint", "vignette", "cornerRadius", "shake", "rgbSplit", "grain",
"fontSize", "letterSpacing", "glow"];
@@ -374,6 +374,7 @@ const project = {
outPoint: null, // timeline work-area OUT (seconds), or null
disabledTracks: [], // track ids omitted from preview/export when listed
tracks: null, // optional [{id, kind}] — null means default V3…V1 + A1…A4
+ panSchema: 1, // 1 = pan-aware; gates one-time L/R stem migration on load
};
const state = {
time: 0, playing: false, pps: 60, snap: true,
@@ -772,19 +773,30 @@ function applyProject(data) {
state.disabledTracks = new Set(disabledTracks);
state.soloId = null;
state.soloRestore = null;
+ // One-shot migration for projects saved before props.pan existed. Gated by
+ // panSchema so a later write that omits pan:0 (compact MCP / agent rebuild)
+ // does not re-hard-pan a deliberately centered stem.
+ const migratePan = !(data.panSchema >= 1);
for (const c of project.clips) {
- c.props = { ...DEFAULT_PROPS, ...(c.props || {}) };
+ const raw = c.props || {};
+ c.props = { ...DEFAULT_PROPS, ...raw };
+ if (migratePan && !Object.hasOwn(raw, "pan") && Number.isInteger(raw.audioChannel) && raw.audioChannel >= 0)
+ c.props.pan = defaultPanForChannel(raw.audioChannel);
if (c.keyframes) for (const arr of Object.values(c.keyframes))
if (Array.isArray(arr)) arr.sort((a, b) => a.t - b.t);
if (c.kind === "text") ensureFont(c.props.font);
}
+ project.panSchema = 1;
+ if (migratePan) scheduleSave(); // persist marker + migrated stem pans
// AV links aren't always on disk (older saves / agents) — rebuild from matching timing.
relinkClips();
// reset runtime playback elements so they rebuild against new data
if (state.audioHold) setAudioHold(false);
else stopAudioHoldNodes();
- for (const el of runtime.clipEls.values()) { try { el.pause(); el.src = ""; } catch { } }
- runtime.clipEls.clear(); runtime.clipGain.clear();
+ // Tear down each clip's Web Audio chain (src→split→gain→panner→bus) before
+ // clearing the maps — otherwise nodes stay wired to live track buses and leak.
+ for (const id of new Set([...runtime.clipEls.keys(), ...runtime.clipGain.keys()]))
+ releaseClipEl(id);
if (runtime.audio) syncAudioGraphTracks();
els.preview.width = project.width; els.preview.height = project.height;
els.monitorRes.textContent = `${project.width} × ${project.height} · ${project.fps}fps`;
@@ -819,6 +831,7 @@ function projectJSON() {
const { name, width, height, fps, background, revision, folders, media, clips, markers, inPoint, outPoint, disabledTracks } = project;
return {
name, width, height, fps, background, revision,
+ panSchema: 1,
folders: (folders || []).map(({ id, name, parentId, open }) =>
({ id, name, parentId: parentId || null, open: open !== false })),
tracks: serializeTracks(),
@@ -1463,6 +1476,13 @@ function audioChannelLong(ch) {
if (!Number.isInteger(ch) || ch < 0) return null;
return CHANNEL_LONG[ch] || `Channel ${ch + 1}`;
}
+/** Default stereo pan for an isolated linked stem (L −1, R +1, else center). */
+function defaultPanForChannel(ch) {
+ if (ch === 0) return -1;
+ if (ch === 1) return 1;
+ return 0;
+}
+function clipPan(v) { return clamp(+v || 0, -1, 1); }
/** How many linked A-track stems we can create for a media item. */
function linkedAudioChannelCount(m) {
const n = Math.max(1, m.channels | 0);
@@ -1513,7 +1533,7 @@ function attachLinkedAudioChannels(videoClip, m, nCh) {
id: "c_" + uid(), mediaId: m.id, kind: "audio", track: ids[ch],
start: videoClip.start, in: videoClip.in, duration: videoClip.duration,
name: videoClip.name,
- props: { ...DEFAULT_PROPS, audioChannel: ch },
+ props: { ...DEFAULT_PROPS, audioChannel: ch, pan: defaultPanForChannel(ch) },
linkGroup: lg,
};
if (videoClip.props?.speed != null) a.props.speed = videoClip.props.speed;
@@ -1522,18 +1542,13 @@ function attachLinkedAudioChannels(videoClip, m, nCh) {
}
return out;
}
-/** Wire a single source channel into the stereo bus: L→left, R→right, else→center. */
+/** Tap one source channel into a mono gain (pan places it in the stereo field). */
function connectIsolatedChannel(ctx, srcNode, gainNode, ch, nCh) {
const outputs = Math.max(2, nCh | 0, (ch | 0) + 1);
const splitter = ctx.createChannelSplitter(outputs);
- const merger = ctx.createChannelMerger(2);
srcNode.connect(splitter);
splitter.connect(gainNode, ch);
- if (ch === 0) gainNode.connect(merger, 0, 0);
- else if (ch === 1) gainNode.connect(merger, 0, 1);
- else { gainNode.connect(merger, 0, 0); gainNode.connect(merger, 0, 1); }
gainNode._fcSplit = splitter;
- gainNode._fcOut = merger;
gainNode._fcChannel = ch;
}
@@ -1631,7 +1646,7 @@ async function reconcileAudioChannels(videoClip) {
project.clips.push({
id: "c_" + uid(), mediaId, kind: "audio", track: ids[ch],
start: live.start, in: live.in, duration: live.duration, name: live.name,
- props: { ...DEFAULT_PROPS, audioChannel: ch },
+ props: { ...DEFAULT_PROPS, audioChannel: ch, pan: defaultPanForChannel(ch) },
linkGroup: lg,
});
added++;
@@ -3282,6 +3297,7 @@ function renderInspector(lite) {
html += `
Audio / Time
${chLabel ? row("Channel", `${chLabel}`) : ""}
${slider("volume", 0, 2, 0.01, p.volume)}
+ ${slider("pan", -1, 1, 0.01, p.pan)}
${slider("speed", 0.25, 4, 0.05, p.speed, "×")}
`;
}
@@ -3408,6 +3424,7 @@ function renderInspector(lite) {
else { c.props[k] = v; if (k === "text") state.dirtyTimeline = true; }
const valEl = els.inspector.querySelector(`[data-val="${k}"]`);
if (valEl) valEl.textContent = input.value;
+ if (state.audioHold && (k === "volume" || k === "pan")) scheduleAudioHoldRefresh();
scheduleSave();
});
input.addEventListener("focus", () => pushUndo(), { once: true });
@@ -3491,7 +3508,7 @@ function renderInspector(lite) {
/* ── Keyframe graphs (program-monitor left gutter) ── */
const KF_GRAPH_LABEL = {
x: "Pos X", y: "Pos Y", scale: "Scale", rotation: "Rotation", opacity: "Opacity",
- volume: "Volume", speed: "Speed", brightness: "Bright", contrast: "Contrast",
+ volume: "Volume", pan: "Pan", speed: "Speed", brightness: "Bright", contrast: "Contrast",
saturation: "Sat", hue: "Hue", blur: "Blur", grayscale: "Gray", sepia: "Sepia",
invert: "Invert", temperature: "Temp", tint: "Tint", vignette: "Vignette",
cornerRadius: "Radius", shake: "Shake", rgbSplit: "RGB", grain: "Grain",
@@ -3670,9 +3687,14 @@ function releaseClipEl(id) {
if (el) { try { el.pause(); el.src = ""; } catch { } runtime.clipEls.delete(id); }
const g = runtime.clipGain.get(id);
if (g) {
+ const out = g._fcOut || g;
+ try { out.disconnect(); } catch {}
+ out._fcBus = null;
+ if (g._fcPanner && g._fcPanner !== out) { try { g._fcPanner.disconnect(); } catch {} }
try { g.disconnect(); } catch {}
- if (g._fcOut) { try { g._fcOut.disconnect(); } catch {} }
if (g._fcSplit) { try { g._fcSplit.disconnect(); } catch {} }
+ if (g._fcSrc) { try { g._fcSrc.disconnect(); } catch {} }
+ g._fcOut = g._fcPanner = g._fcSplit = g._fcSrc = null;
runtime.clipGain.delete(id);
}
}
@@ -3704,18 +3726,31 @@ function ensureAudio() {
}
return runtime.audio;
}
-/** Route `src -> g -> (channel-isolated ->) out` on `ctx`. When `ch` is set,
- * only that source channel is fed through `g` (L→left, R→right, else→center).
- * Returns the node to connect downstream plus split/merge nodes (null when
- * unused) so callers can track them for later disconnect/dispose. Shared by
- * hookAudio, refreshAudioHold and the offline export mixdown. */
+/** Route `src → gain → (optional channel split) → stereoPanner → bus`.
+ * When `ch` is set, only that source channel feeds the gain (mono); pan places
+ * it in the stereo field. Returns split node when used (for dispose). */
function connectChannelIsolated(ctx, src, g, ch, nCh) {
if (Number.isInteger(ch) && ch >= 0) {
connectIsolatedChannel(ctx, src, g, ch, nCh);
- return { out: g._fcOut, split: g._fcSplit, merge: g._fcOut };
+ return { split: g._fcSplit };
}
src.connect(g);
- return { out: g, split: null, merge: null };
+ return { split: null };
+}
+/** Gain → StereoPanner; panner becomes `_fcOut` for routing.
+ * Falls back to gain-as-out when StereoPannerNode is unavailable. */
+function attachClipPanner(ctx, g) {
+ try {
+ const panner = ctx.createStereoPanner();
+ g.connect(panner);
+ g._fcPanner = panner;
+ g._fcOut = panner;
+ return panner;
+ } catch {
+ g._fcPanner = null;
+ g._fcOut = g;
+ return null;
+ }
}
/** Create missing A-track buses and rebuild the meter when the track list changes. */
function syncAudioGraphTracks() {
@@ -3759,8 +3794,13 @@ function hookAudio(c, el) {
if (c.kind !== "video" && c.kind !== "audio") return;
try {
const ctx = runtime.audio.ctx;
+ // createMediaElementSource irreversibly diverts element audio into the
+ // graph — register the gain first so releaseClipEl can always tear it down
+ // even if a later step throws.
const src = ctx.createMediaElementSource(el);
const g = ctx.createGain();
+ g._fcSrc = src;
+ runtime.clipGain.set(c.id, g);
const ch = c.props?.audioChannel;
if (Number.isInteger(ch) && ch >= 0) {
const m = getMedia(c.mediaId);
@@ -3770,9 +3810,12 @@ function hookAudio(c, el) {
} else {
src.connect(g);
}
- runtime.clipGain.set(c.id, g);
+ attachClipPanner(ctx, g); // degrades to gain-as-out if StereoPanner fails
routeClipGain(c);
- } catch {}
+ } catch {
+ // Element source was created but graph setup failed — keep the map entry
+ // so a later releaseClipEl can disconnect whatever did get wired.
+ }
}
/** Reconnect a clip's gain to the correct track bus (or master for video tracks). */
function routeClipGain(c) {
@@ -3782,6 +3825,7 @@ function routeClipGain(c) {
const out = g._fcOut || g;
if (out._fcBus === bus) return;
try { out.disconnect(); } catch {}
+ out._fcBus = null;
out.connect(bus);
out._fcBus = bus;
}
@@ -4081,8 +4125,8 @@ function disposeAudioHoldNode(n) {
try { n.src.disconnect(); } catch { }
try { if (n.src) n.src.buffer = null; } catch { }
try { n.gain.disconnect(); } catch { }
+ if (n.panner) { try { n.panner.disconnect(); } catch { } }
if (n.split) { try { n.split.disconnect(); } catch { } }
- if (n.merge) { try { n.merge.disconnect(); } catch { } }
}
function stopAudioHoldNodes() {
audioHoldGen++;
@@ -4147,12 +4191,15 @@ function refreshAudioHold() {
src.loop = true;
const g = audio.ctx.createGain();
g.gain.value = vol;
+ const panner = audio.ctx.createStereoPanner();
+ panner.pan.value = clipPan(p.pan);
+ g.connect(panner);
const ch = c.props?.audioChannel;
const nCh = Math.max(buf.numberOfChannels, Number.isInteger(ch) ? ch + 1 : 0, 2);
- const { out, split, merge } = connectChannelIsolated(audio.ctx, src, g, ch, nCh);
+ const { split } = connectChannelIsolated(audio.ctx, src, g, ch, nCh);
const bus = audio.trackBus[c.track] || audio.master;
- out.connect(bus);
- const node = { src, gain: g, split, merge };
+ panner.connect(bus);
+ const node = { src, gain: g, panner, split };
try { src.start(0); } catch { disposeAudioHoldNode(node); return; }
// Re-check after start: a newer refresh/stop may have run while we built the graph.
if (gen !== audioHoldGen || !state.audioHold || state.playing) {
@@ -4223,7 +4270,10 @@ function syncMedia() {
if (Math.abs(el.currentTime - mt) > 0.25 * eff) { try { el.currentTime = mt; } catch {} }
const vol = clamp(p.volume, 0, 4);
const g = runtime.clipGain.get(c.id);
- if (g) g.gain.value = vol;
+ if (g) {
+ g.gain.value = vol;
+ if (g._fcPanner) g._fcPanner.pan.value = clipPan(p.pan);
+ }
else el.volume = clamp(vol, 0, 1);
} else {
if (!el.paused) el.pause();
@@ -5612,16 +5662,27 @@ async function renderAudioMix(dur) {
for (const { c, buf } of sources) {
const src = off.createBufferSource(); src.buffer = buf;
const g = off.createGain();
+ const panner = off.createStereoPanner();
+ g.connect(panner);
const n = Math.max(2, Math.ceil(c.duration * 30));
- const curve = new Float32Array(n);
- for (let i = 0; i < n; i++)
- curve[i] = clamp(evalProps(c, c.start + (i / (n - 1)) * c.duration).volume, 0, 4);
- g.gain.setValueCurveAtTime(curve, Math.max(0, c.start), Math.max(0.01, c.duration));
+ const volCurve = new Float32Array(n);
+ const panCurve = new Float32Array(n);
+ for (let i = 0; i < n; i++) {
+ const ep = evalProps(c, c.start + (i / (n - 1)) * c.duration);
+ volCurve[i] = clamp(ep.volume, 0, 4);
+ panCurve[i] = clipPan(ep.pan);
+ }
+ g.gain.setValueCurveAtTime(volCurve, Math.max(0, c.start), Math.max(0.01, c.duration));
+ try {
+ panner.pan.setValueCurveAtTime(panCurve, Math.max(0, c.start), Math.max(0.01, c.duration));
+ } catch {
+ panner.pan.value = panCurve[0] ?? 0;
+ }
const ch = c.props?.audioChannel;
- const { out } = Number.isInteger(ch) && ch >= 0
- ? connectChannelIsolated(off, src, g, ch, Math.max(buf.numberOfChannels, ch + 1, 2))
- : (src.connect(g), { out: g });
- out.connect(off.destination);
+ if (Number.isInteger(ch) && ch >= 0)
+ connectChannelIsolated(off, src, g, ch, Math.max(buf.numberOfChannels, ch + 1, 2));
+ else src.connect(g);
+ panner.connect(off.destination);
if (hasSpeedRamp(c)) {
const rc = new Float32Array(n);
for (let i = 0; i < n; i++)
diff --git a/mcp-server.js b/mcp-server.js
index 08fac15..6c2b222 100644
--- a/mcp-server.js
+++ b/mcp-server.js
@@ -187,7 +187,7 @@ async function callTool(name, args) {
// the UI persists default-valued props on every clip; hide them so the
// compact view only shows what actually deviates
const DEFAULTS = {
- x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, speed: 1,
+ x: 0, y: 0, scale: 1, rotation: 0, opacity: 1, volume: 1, pan: 0, speed: 1,
blend: "normal", fit: "contain", cropL: 0, cropR: 0, cropT: 0, cropB: 0,
cornerRadius: 0, flipH: false, flipV: false, filterPreset: "none",
brightness: 100, contrast: 100, saturation: 100, hue: 0, temperature: 0,
@@ -208,12 +208,19 @@ async function callTool(name, args) {
const kept = {};
for (const [k, v] of Object.entries(o)) {
if (kind !== "text" && k === "text" && v === "Title") continue;
+ // Always keep pan on linked stems — pan:0 (center) is meaningful and
+ // must not be stripped, or agents rebuilding from compact can lose it.
+ if (k === "pan" && Number.isInteger(o.audioChannel) && o.audioChannel >= 0) {
+ kept[k] = v;
+ continue;
+ }
if (hex(DEFAULTS[k]) !== hex(v)) kept[k] = v;
}
return Object.keys(kept).length ? " " + JSON.stringify(kept) : "";
};
const lines = [
`"${doc.name}" ${doc.width}x${doc.height}@${doc.fps} rev:${doc.revision}` +
+ (doc.panSchema >= 1 ? " panSchema:1" : "") +
(doc.background ? ` bg:${doc.background}` : "") +
(doc.markers?.length ? ` markers:${doc.markers.length} [${doc.markers.slice(0, 12).map((m) => m.t).join(",")}${doc.markers.length > 12 ? ",…" : ""}]` : ""),
`MEDIA (${doc.media.length}):`,
@@ -343,6 +350,9 @@ async function callTool(name, args) {
`Pass force:true only if the user explicitly wants those changes discarded.`);
}
doc.revision = Math.max(curRev + 1, (doc.revision || 0));
+ // Agents often omit top-level flags they don't know about. Keep panSchema
+ // once established so a rewrite that drops pan:0 cannot re-trigger L/R migration.
+ if (!(doc.panSchema >= 1) && (cur.panSchema >= 1)) doc.panSchema = 1;
writeProject(doc);
lastReadRevision = doc.revision;
return `Saved (revision ${doc.revision}). ${doc.clips.length} clip(s). The editor UI (if open at ${BASE}) has hot-reloaded.`;
From 08391a0421f9a8f52ffc106cf24bc7e2b77e8f75 Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 30 Jul 2026 12:12:13 +0300
Subject: [PATCH 10/11] feat: add master audio L/R meter
---
CHANGELOG.md | 3 +
app.js | 153 +++++++++++++++++++++++++++++++++++------------
meter-worklet.js | 82 ++++++++++++++++++++++---
style.css | 8 +++
4 files changed, 200 insertions(+), 46 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e62413..459b284 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
stems default to L `−1` / R `+1` / center for other channels; projects saved
before pan migrate once via `panSchema` (so omitting `pan: 0` later does not
re-hard-pan a centered stem). Compact MCP keeps `pan` on linked stems.
+- Stereo **master meter** (L/R) on the monitor — post-pan program sum in the
+ same RMS / LUFS / Peak modes as the per-track bars; A-tracks + video spill
+ route through one summed path when the meter worklet is active.
- Preview playback speed — a monitor toolbar toggle plus **J**/**K**/**L** shortcuts cycle the preview player through 1×, 1.5×, 2×, and 4× (L faster, J slower, K play/pause and reset to 1×). It rides on top of each clip's own speed and is forced back to 1× during export, so renders always come out at real time.
### Fixed
diff --git a/app.js b/app.js
index 557a400..9d5aca5 100644
--- a/app.js
+++ b/app.js
@@ -3878,6 +3878,16 @@ function paintMeterSegs(entry, lit, hold) {
}
}
}
+const MASTER_METER_L = "M:L";
+const MASTER_METER_R = "M:R";
+const MASTER_METER_IDS = [MASTER_METER_L, MASTER_METER_R];
+function resetMasterMeterBallistics() {
+ meterState.master.dispL = meterState.master.dispR = METER_DB_MIN;
+ meterState.master.peakHoldL = meterState.master.peakHoldR = METER_DB_MIN;
+ meterState.master.peakHoldTL = meterState.master.peakHoldTR = 0;
+ meterState.lastLit[MASTER_METER_L] = meterState.lastLit[MASTER_METER_R] = -1;
+ meterState.lastHold[MASTER_METER_L] = meterState.lastHold[MASTER_METER_R] = -1;
+}
const meterState = {
mode: (() => {
try {
@@ -3896,6 +3906,10 @@ const meterState = {
lastLit: {}, // id -> last-painted lit/hold seg indices, to skip redundant DOM writes
lastHold: {},
modeBtn: null,
+ master: { rmsL: 0, rmsR: 0, peakL: 0, peakR: 0, lufs: -70,
+ dispL: METER_DB_MIN, dispR: METER_DB_MIN,
+ peakHoldL: METER_DB_MIN, peakHoldR: METER_DB_MIN,
+ peakHoldTL: 0, peakHoldTR: 0 },
};
function audioMeterTracks() {
return TRACKS.filter((t) => t.kind === "audio");
@@ -3914,21 +3928,23 @@ function cycleMeterMode(ev) {
meterState.peakHold[id] = METER_DB_MIN;
meterState.peakHoldT[id] = 0;
}
+ resetMasterMeterBallistics();
}
async function installMeterWorklet(audio) {
if (audio.meterReady || !audio.ctx.audioWorklet || meterState._loading) return;
meterState._loading = true;
const trackIds = audio.audioTrackIds.slice();
+ const nAudio = trackIds.length;
+ const nInputs = Math.max(1, nAudio + 1); // +1 = video/other spill on master
try {
- await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=2");
- const n = trackIds.length;
+ await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=3");
const meter = new AudioWorkletNode(audio.ctx, "fablecut-meter", {
- numberOfInputs: n,
+ numberOfInputs: nInputs,
numberOfOutputs: 1,
outputChannelCount: [2],
channelCount: 2,
channelCountMode: "explicit",
- processorOptions: { hopBlocks: 8, nTracks: n, trackIds },
+ processorOptions: { hopBlocks: 8, nTracks: nInputs, nAudioTracks: nAudio, trackIds },
});
meter.port.onmessage = (ev) => {
const msg = ev.data;
@@ -3940,17 +3956,25 @@ async function installMeterWorklet(audio) {
meterState.peak[id] = msg.peak[i] || 0;
meterState.lufs[id] = msg.lufs[i] != null ? msg.lufs[i] : -70;
}
+ if (msg.master) {
+ const m = msg.master;
+ meterState.master.rmsL = m.rmsL || 0;
+ meterState.master.rmsR = m.rmsR || 0;
+ meterState.master.peakL = m.peakL || 0;
+ meterState.master.peakR = m.peakR || 0;
+ meterState.master.lufs = m.lufs != null ? m.lufs : -70;
+ }
};
- // Reroute: trackBus → meter inputs → speakers/rec (no double via master)
+ // Full mix: A-buses + master spill → meter → speakers/rec (single summed path)
for (const id of trackIds) {
const bus = audio.trackBus[id];
try { bus.disconnect(); } catch {}
bus.connect(meter, 0, trackIds.indexOf(id));
}
- // master still carries video-track embedded audio (recDest already wired)
try { audio.master.disconnect(audio.ctx.destination); } catch {}
- audio.master.connect(audio.ctx.destination);
+ try { audio.master.disconnect(audio.recDest); } catch {}
+ audio.master.connect(meter, 0, nAudio);
meter.connect(audio.ctx.destination);
meter.connect(audio.recDest);
@@ -3965,6 +3989,7 @@ async function installMeterWorklet(audio) {
meterState.peakHold[id] = METER_DB_MIN;
meterState.peakHoldT[id] = 0;
}
+ resetMasterMeterBallistics();
buildMeterDOM();
} catch (err) {
console.warn("[FableCut] meter worklet unavailable:", err);
@@ -4007,6 +4032,7 @@ function buildMeterDOM() {
meterState.lastLit = {};
meterState.lastHold = {};
meterState.trackIds = tracks.map((t) => t.id);
+
for (const t of tracks) {
if (meterState.disp[t.id] == null) {
meterState.disp[t.id] = METER_DB_MIN;
@@ -4031,6 +4057,27 @@ function buildMeterDOM() {
col.appendChild(label);
row.appendChild(col);
}
+
+ const masterWrap = document.createElement("div");
+ masterWrap.className = "vu-master";
+ for (const ch of ["L", "R"]) {
+ const id = ch === "L" ? MASTER_METER_L : MASTER_METER_R;
+ const col = document.createElement("div");
+ col.className = "vu-channel vu-master-ch";
+ col.dataset.track = id;
+ const segsCv = document.createElement("canvas");
+ segsCv.className = "vu-segs";
+ const entry = makeMeterCanvas(segsCv);
+ meterState.segs[id] = entry;
+ paintMeterSegs(entry, 0, -1);
+ const label = document.createElement("span");
+ label.className = "vu-label";
+ label.textContent = ch;
+ col.appendChild(segsCv);
+ col.appendChild(label);
+ masterWrap.appendChild(col);
+ }
+ row.appendChild(masterWrap);
root.appendChild(row);
}
function rmsToDb(rms) {
@@ -4038,6 +4085,16 @@ function rmsToDb(rms) {
}
function meterReadingDb(id) {
const mode = meterState.mode;
+ if (id === MASTER_METER_L || id === MASTER_METER_R) {
+ const m = meterState.master;
+ const isL = id === MASTER_METER_L;
+ if (mode === "peak") return rmsToDb(isL ? m.peakL : m.peakR);
+ if (mode === "lufs") {
+ const v = m.lufs;
+ return v == null || v < METER_DB_MIN ? METER_DB_MIN : Math.min(METER_DB_MAX, v);
+ }
+ return rmsToDb(isL ? m.rmsL : m.rmsR);
+ }
if (mode === "peak") return rmsToDb(meterState.peak[id] || 0);
if (mode === "lufs") {
const v = meterState.lufs[id];
@@ -4045,9 +4102,55 @@ function meterReadingDb(id) {
}
return rmsToDb(meterState.rms[id] || 0);
}
+function updateMeterChannel(id, target, dt, attack, release, now) {
+ const curKey = id === MASTER_METER_L ? "dispL" : id === MASTER_METER_R ? "dispR" : null;
+ const holdKey = id === MASTER_METER_L ? "peakHoldL" : id === MASTER_METER_R ? "peakHoldR" : null;
+ const holdTKey = id === MASTER_METER_L ? "peakHoldTL" : id === MASTER_METER_R ? "peakHoldTR" : null;
+ let cur, peakHold, peakHoldT;
+ if (curKey) {
+ cur = meterState.master[curKey] ?? METER_DB_MIN;
+ peakHold = meterState.master[holdKey] ?? METER_DB_MIN;
+ peakHoldT = meterState.master[holdTKey] || 0;
+ } else {
+ cur = meterState.disp[id] ?? METER_DB_MIN;
+ peakHold = meterState.peakHold[id] ?? METER_DB_MIN;
+ peakHoldT = meterState.peakHoldT[id] || 0;
+ }
+ const a = target > cur ? attack : release;
+ const next = cur + (target - cur) * a;
+ if (curKey) meterState.master[curKey] = next;
+ else meterState.disp[id] = next;
+
+ const pk = target;
+ if (pk >= peakHold) {
+ if (curKey) {
+ meterState.master[holdKey] = pk;
+ meterState.master[holdTKey] = now;
+ } else {
+ meterState.peakHold[id] = pk;
+ meterState.peakHoldT[id] = now;
+ }
+ peakHold = pk;
+ } else if (now - peakHoldT > 800) {
+ peakHold += (METER_DB_MIN - peakHold) * release;
+ if (curKey) meterState.master[holdKey] = peakHold;
+ else meterState.peakHold[id] = peakHold;
+ }
+
+ const segs = meterState.segs[id];
+ if (!segs) return;
+ const level = (next - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN);
+ const lit = Math.round(clamp(level, 0, 1) * METER_SEGS);
+ const hold = Math.round(clamp(
+ (peakHold - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN), 0, 1
+ ) * (METER_SEGS - 1));
+ if (meterState.lastLit[id] === lit && meterState.lastHold[id] === hold) return;
+ meterState.lastLit[id] = lit;
+ meterState.lastHold[id] = hold;
+ paintMeterSegs(segs, lit, hold);
+}
function updateMeterUI(dt) {
const ids = meterState.trackIds;
- if (!ids.length) return;
const metering = (state.playing || state.audioHold) && runtime.audio?.meterReady;
const mode = meterState.mode;
// Peak: snappy; LUFS already smoothed in-worklet (400 ms); RMS: classic VU feel
@@ -4056,36 +4159,10 @@ function updateMeterUI(dt) {
const attack = 1 - Math.exp(-dt / atkMs);
const release = 1 - Math.exp(-dt / relMs);
const now = performance.now();
- for (const id of ids) {
- const target = metering ? meterReadingDb(id) : METER_DB_MIN;
- const cur = meterState.disp[id] ?? METER_DB_MIN;
- const a = target > cur ? attack : release;
- const next = cur + (target - cur) * a;
- meterState.disp[id] = next;
-
- // Hold tip follows the active mode reading (not always sample-peak)
- const pk = target;
- if (pk >= (meterState.peakHold[id] ?? METER_DB_MIN)) {
- meterState.peakHold[id] = pk;
- meterState.peakHoldT[id] = now;
- } else if (now - (meterState.peakHoldT[id] || 0) > 800) {
- meterState.peakHold[id] += (METER_DB_MIN - meterState.peakHold[id]) * release;
- }
-
- const segs = meterState.segs[id];
- if (!segs) continue;
- const level = (next - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN);
- const lit = Math.round(clamp(level, 0, 1) * METER_SEGS);
- const hold = Math.round(clamp(
- ((meterState.peakHold[id] ?? METER_DB_MIN) - METER_DB_MIN) / (METER_DB_MAX - METER_DB_MIN), 0, 1
- ) * (METER_SEGS - 1));
- // The ballistics above still run every frame (needed for smooth decay),
- // but the canvas only needs repainting when the result actually differs —
- // skips a redraw for most tracks most frames.
- if (meterState.lastLit[id] === lit && meterState.lastHold[id] === hold) continue;
- meterState.lastLit[id] = lit;
- meterState.lastHold[id] = hold;
- paintMeterSegs(segs, lit, hold);
+ const floor = METER_DB_MIN;
+ for (const id of [...ids, ...MASTER_METER_IDS]) {
+ const target = metering ? meterReadingDb(id) : floor;
+ updateMeterChannel(id, target, dt, attack, release, now);
}
}
diff --git a/meter-worklet.js b/meter-worklet.js
index 8be575c..cf82b5a 100644
--- a/meter-worklet.js
+++ b/meter-worklet.js
@@ -1,7 +1,6 @@
-/* FableCut per-track meter worklet.
- Each input = one timeline audio track. Pass-through mix to stereo out.
- Reports per-track: RMS, sample peak, and momentary LUFS (ITU-R BS.1770-4,
- 400 ms, K-weighted stereo). */
+/* FableCut per-track + stereo master meter worklet.
+ Inputs 0…nAudio−1 = A-track buses; input nAudio = video/other spill on master.
+ Pass-through sum → stereo out. Reports per A-track + post-sum master L/R. */
function shelfCoeffs(fs) {
const f0 = 1681.974450955533;
const G = 3.999843853973347;
@@ -47,6 +46,7 @@ class FableCutMeterProcessor extends AudioWorkletProcessor {
const opts = (options && options.processorOptions) || {};
this._hopBlocks = Math.max(1, opts.hopBlocks || 8);
this._nTracks = Math.max(1, opts.nTracks || 1);
+ this._nAudio = Math.max(0, opts.nAudioTracks ?? opts.nTracks ?? 1);
this._ids = opts.trackIds || [];
this._block = 0;
this._sumSq = new Float64Array(this._nTracks);
@@ -78,6 +78,20 @@ class FableCutMeterProcessor extends AudioWorkletProcessor {
this._lufsIdx.push(0);
this._lufsFilled.push(0);
}
+
+ // Stereo master (post-sum L/R + momentary stereo LUFS)
+ this._mSumSqL = 0;
+ this._mSumSqR = 0;
+ this._mPeakL = 0;
+ this._mPeakR = 0;
+ this._mSumSqK = 0;
+ this._mShelfL = makeBiquad(shelf);
+ this._mHpfL = makeBiquad(hpf);
+ this._mShelfR = makeBiquad(shelf);
+ this._mHpfR = makeBiquad(hpf);
+ this._mLufsRing = new Float64Array(this._lufsLen);
+ this._mLufsIdx = 0;
+ this._mLufsFilled = 0;
}
process(inputs, outputs) {
@@ -124,15 +138,43 @@ class FableCutMeterProcessor extends AudioWorkletProcessor {
this._sumSqK[t] = sumK;
}
+ if (outL && frames) {
+ let mSqL = this._mSumSqL;
+ let mSqR = this._mSumSqR;
+ let mPkL = this._mPeakL;
+ let mPkR = this._mPeakR;
+ let mSqK = this._mSumSqK;
+ const mSL = this._mShelfL, mHL = this._mHpfL;
+ const mSR = this._mShelfR, mHR = this._mHpfR;
+ for (let i = 0; i < frames; i++) {
+ const l = outL[i];
+ const r = outR ? outR[i] : l;
+ mSqL += l * l;
+ mSqR += r * r;
+ const aL = l >= 0 ? l : -l;
+ const aR = r >= 0 ? r : -r;
+ if (aL > mPkL) mPkL = aL;
+ if (aR > mPkR) mPkR = aR;
+ const fl = biquadStep(mHL, biquadStep(mSL, l));
+ const fr = biquadStep(mHR, biquadStep(mSR, r));
+ mSqK += fl * fl + fr * fr;
+ }
+ this._mSumSqL = mSqL;
+ this._mSumSqR = mSqR;
+ this._mPeakL = mPkL;
+ this._mPeakR = mPkR;
+ this._mSumSqK = mSqK;
+ }
+
if (frames) this._frames += frames;
this._block++;
if (this._block >= this._hopBlocks) {
const n = Math.max(1, this._frames);
- const rms = new Array(this._nTracks);
- const peak = new Array(this._nTracks);
- const lufs = new Array(this._nTracks);
- for (let t = 0; t < this._nTracks; t++) {
+ const rms = new Array(this._nAudio);
+ const peak = new Array(this._nAudio);
+ const lufs = new Array(this._nAudio);
+ for (let t = 0; t < this._nAudio; t++) {
rms[t] = Math.sqrt(this._sumSq[t] / n);
peak[t] = this._peak[t];
@@ -153,12 +195,36 @@ class FableCutMeterProcessor extends AudioWorkletProcessor {
this._peak[t] = 0;
this._sumSqK[t] = 0;
}
+
+ const blockMs = this._mSumSqK / n;
+ const mRing = this._mLufsRing;
+ const mIdx = this._mLufsIdx;
+ mRing[mIdx] = blockMs;
+ this._mLufsIdx = (mIdx + 1) % this._lufsLen;
+ if (this._mLufsFilled < this._lufsLen) this._mLufsFilled++;
+ let mAcc = 0;
+ for (let i = 0; i < this._mLufsFilled; i++) mAcc += mRing[i];
+ const mMeanMs = mAcc / Math.max(1, this._mLufsFilled);
+ const master = {
+ rmsL: Math.sqrt(this._mSumSqL / n),
+ rmsR: Math.sqrt(this._mSumSqR / n),
+ peakL: this._mPeakL,
+ peakR: this._mPeakR,
+ lufs: mMeanMs > 1e-12 ? -0.691 + 10 * Math.log10(mMeanMs) : -70,
+ };
+ this._mSumSqL = 0;
+ this._mSumSqR = 0;
+ this._mPeakL = 0;
+ this._mPeakR = 0;
+ this._mSumSqK = 0;
+
this.port.postMessage({
type: "meter",
trackIds: this._ids,
rms,
peak,
lufs,
+ master,
frames: n,
});
this._block = 0;
diff --git a/style.css b/style.css
index 678ed8c..9b07b60 100644
--- a/style.css
+++ b/style.css
@@ -893,6 +893,14 @@ body.track-size-s .clip.selected {
}
.vu-scale-tick:first-child { transform: translateY(0); }
.vu-scale-tick:last-child { transform: translateY(-100%); }
+.vu-master {
+ display: flex;
+ gap: 2px;
+ padding-left: 5px;
+ margin-left: 3px;
+ border-left: 1px solid #ffffff18;
+}
+.vu-master .vu-label { color: #d4d4dc; }
.vu-channel {
display: flex;
flex-direction: column;
From 09c1180a3a5431a406d2f3975286af3f293a33cf Mon Sep 17 00:00:00 2001
From: = <=>
Date: Thu, 30 Jul 2026 13:44:51 +0300
Subject: [PATCH 11/11] feat: Improve the audio graph wiring, add collapsible
tracks's meters
---
CHANGELOG.md | 3 +-
app.js | 287 ++++++++++++++++++++++++++++++++++-------------
meter-worklet.js | 88 ++-------------
style.css | 85 +++++++++++---
4 files changed, 296 insertions(+), 167 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 459b284..b54cbf6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
re-hard-pan a centered stem). Compact MCP keeps `pan` on linked stems.
- Stereo **master meter** (L/R) on the monitor — post-pan program sum in the
same RMS / LUFS / Peak modes as the per-track bars; A-tracks + video spill
- route through one summed path when the meter worklet is active.
+ route through one summed path when the meter worklet is active. Per-track
+ bars collapse via **◂** / **▸** beside the master strip (master L/R stay visible).
- Preview playback speed — a monitor toolbar toggle plus **J**/**K**/**L** shortcuts cycle the preview player through 1×, 1.5×, 2×, and 4× (L faster, J slower, K play/pause and reset to 1×). It rides on top of each clip's own speed and is forced back to 1× during export, so renders always come out at real time.
### Fixed
diff --git a/app.js b/app.js
index 9d5aca5..0fdb14f 100644
--- a/app.js
+++ b/app.js
@@ -231,25 +231,6 @@ function addTimelineTrack(kind) {
scheduleSave();
return t;
}
-/* Add A5, A6, … until there are `need` audio tracks (capped at MAX_AUDIO_TRACKS),
- so multi-channel sources beyond stereo/quad (5.1, 7.1…) each get their own
- linked audio track. Returns how many were added. */
-function ensureAudioTrackCount(need) {
- need = Math.min(need, MAX_AUDIO_TRACKS);
- let added = 0;
- while (audioTrackIds().length < need) {
- TRACKS.push(makeTrack(nextTrackId("audio"), "audio"));
- added++;
- }
- if (added) {
- sortTracksInPlace();
- applyTrackHeights();
- project.tracks = serializeTracks();
- buildTrackDOM();
- if (runtime.audio) syncAudioGraphTracks();
- }
- return added;
-}
function trackHasClips(trackId) {
return project.clips.some((c) => c.track === trackId);
}
@@ -3698,15 +3679,27 @@ function releaseClipEl(id) {
runtime.clipGain.delete(id);
}
}
+/** Configure an A-track bus so stereo panner output stays L/R through the meter. */
+function configureTrackBus(g) {
+ g.channelCount = 2;
+ g.channelCountMode = "explicit";
+ g.channelInterpretation = "discrete";
+}
+/** Master spill bus (V-track / direct video audio) — same stereo rules as A-buses. */
+function configureMasterBus(g) {
+ configureTrackBus(g);
+}
function ensureAudio() {
if (runtime.audio) return runtime.audio;
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const master = ctx.createGain();
+ configureMasterBus(master);
const recDest = ctx.createMediaStreamDestination();
const ids = audioTrackIds();
const trackBus = {};
for (const id of ids) {
trackBus[id] = ctx.createGain();
+ configureTrackBus(trackBus[id]);
}
// Until the worklet is ready, audio-track buses and master both feed speakers.
master.connect(ctx.destination);
@@ -3738,7 +3731,8 @@ function connectChannelIsolated(ctx, src, g, ch, nCh) {
return { split: null };
}
/** Gain → StereoPanner; panner becomes `_fcOut` for routing.
- * Falls back to gain-as-out when StereoPannerNode is unavailable. */
+ * Falls back to gain-as-out when StereoPannerNode is unavailable.
+ * Keep default `speakers` interpretation — `discrete` leaves mono on L at pan 0. */
function attachClipPanner(ctx, g) {
try {
const panner = ctx.createStereoPanner();
@@ -3766,17 +3760,18 @@ function syncAudioGraphTracks() {
}
for (const id of ids) {
if (!audio.trackBus[id]) audio.trackBus[id] = audio.ctx.createGain();
+ configureTrackBus(audio.trackBus[id]);
}
audio.audioTrackIds = ids.slice();
// Tear down meter so installMeterWorklet can rebuild with the new input count.
if (audio.meter) {
- try { audio.meter.disconnect(); } catch { }
- try { audio.master.disconnect(); } catch { }
- audio.master.connect(audio.ctx.destination);
- audio.master.connect(audio.recDest);
+ teardownMeterNode(audio);
audio.meter = null;
- audio.meterReady = false;
}
+ audio.meterReady = false;
+ // Allow a new install even if a previous addModule is still in flight — that
+ // call's finally will see _reloadMeter and run again.
+ meterState._reloadMeter = true;
// Always wire current buses → master so re-added tracks stay audible if meter install fails.
for (const id of ids) {
const g = audio.trackBus[id];
@@ -3836,6 +3831,9 @@ const METER_DB_MIN = -48;
const METER_DB_MAX = 0;
/** Scale tick marks shown beside the meter bars (dBFS). */
const METER_DB_MARKS = [0, -6, -12, -24, -36, -48];
+function meterMarkTopPct(db) {
+ return ((METER_DB_MAX - db) / (METER_DB_MAX - METER_DB_MIN)) * 100;
+}
const METER_MODES = ["rms", "lufs", "peak"];
const METER_MODE_LABEL = { rms: "RMS", lufs: "LUFS", peak: "PEAK" };
/* Each channel's segment ladder is one