From 413cab43daebe45546be6333f1705d6db818dbdb Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 22 Jul 2026 19:36:34 +0300 Subject: [PATCH 01/11] feat: decode all the audio channels from a first track --- app.js | 143 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 97 insertions(+), 46 deletions(-) diff --git a/app.js b/app.js index b412674..3a74178 100644 --- a/app.js +++ b/app.js @@ -804,7 +804,7 @@ function wavePeaksFor(c) { if (w instanceof Float32Array) return w; if (!w.max) return null; const ch = c.props?.audioChannel; - if (Number.isInteger(ch) && w.channels?.[ch]) return w.channels[ch]; + if (Number.isInteger(ch) && ch >= 0 && w.channels?.[ch]) return w.channels[ch]; return w.max; } @@ -1213,7 +1213,7 @@ function linkedClip(c) { return c?.linkedId ? getClip(c.linkedId) : null; } /* Expand a clip list so each AV-linked partner is included once. - Supports N-way `linkGroup` (video + L + R) and legacy pairwise `linkedId`. */ + Supports N-way `linkGroup` (video + per-channel stems) and legacy pairwise `linkedId`. */ function withLinked(clips) { const out = new Map(); const groups = new Set(); @@ -1246,7 +1246,7 @@ function syncLinkedTiming(c) { } /* Rebuild AV linkGroups after load. Unlinking isn't supported, so any video + audio clips that share mediaId and the same start/in/duration belong together - (e.g. picture + L/R stems from one file). Legacy pairwise linkedId is cleared + (e.g. picture + L/R/C stems from one file). Legacy pairwise linkedId is cleared in favor of linkGroup. */ function relinkClips() { const near = (a, b) => Math.abs((+a || 0) - (+b || 0)) < 1e-3; @@ -1275,6 +1275,59 @@ function relinkClips() { } } +/* Discrete-channel labels for linked stems (WAV / Web Audio order). */ +const CHANNEL_SHORT = ["L", "R", "C", "LFE", "Ls", "Rs", "Lb", "Rb"]; +const CHANNEL_LONG = ["Left", "Right", "Center", "LFE", "Surround L", "Surround R", "Back L", "Back R"]; +function audioChannelShort(ch) { + if (!Number.isInteger(ch) || ch < 0) return ""; + return CHANNEL_SHORT[ch] || `Ch${ch + 1}`; +} +function audioChannelLong(ch) { + if (!Number.isInteger(ch) || ch < 0) return null; + return CHANNEL_LONG[ch] || `Channel ${ch + 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); + return Math.min(n, AUDIO_TRACK_IDS.length); +} +/** Attach one audio clip per source channel (A1…A4), sharing the video's linkGroup. */ +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). + project.clips = project.clips.filter((x) => !(x.linkGroup === lg && x.kind === "audio")); + const n = Math.min(Math.max(1, nCh | 0), AUDIO_TRACK_IDS.length); + const out = []; + for (let ch = 0; ch < n; ch++) { + const a = { + id: "c_" + uid(), mediaId: m.id, kind: "audio", track: AUDIO_TRACK_IDS[ch], + start: videoClip.start, in: videoClip.in, duration: videoClip.duration, + name: videoClip.name, + props: { ...DEFAULT_PROPS, audioChannel: ch }, + linkGroup: lg, + }; + if (videoClip.props?.speed != null) a.props.speed = videoClip.props.speed; + project.clips.push(a); + out.push(a); + } + return out; +} +/** Wire a single source channel into the stereo bus: L→left, R→right, else→center. */ +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; +} + function addClipFromMedia(m, trackId, at) { pushUndo(); const kind = m.kind; @@ -1290,25 +1343,25 @@ function addClipFromMedia(m, trackId, at) { props: { ...DEFAULT_PROPS }, }; project.clips.push(c); - // Video+audio: picture on a V track; stereo L/R as separate linked clips on A1/A2. + // Video+audio: picture on a V track; one linked stem per source channel on A1…A4. // Mute the video clip so audio isn't doubled. if (kind === "video") { c.props.volume = 0; - const lg = "lg_" + uid(); - c.linkGroup = lg; - const aL = { - id: "c_" + uid(), mediaId: m.id, kind: "audio", track: "A1", - start, in: 0, duration, name, - props: { ...DEFAULT_PROPS, audioChannel: 0 }, - linkGroup: lg, - }; - const aR = { - id: "c_" + uid(), mediaId: m.id, kind: "audio", track: "A2", - start, in: 0, duration, name, - props: { ...DEFAULT_PROPS, audioChannel: 1 }, - linkGroup: lg, + c.linkGroup = "lg_" + uid(); + const finish = (nCh) => { + if (!getClip(c.id) || !c.linkGroup) return; + m.channels = nCh; + attachLinkedAudioChannels(c, m, linkedAudioChannelCount(m)); + state.dirtyTimeline = true; + scheduleSave(); + renderInspector(); }; - project.clips.push(aL, aR); + if (m.channels > 0) { + finish(m.channels); + } else { + getAudioBuffer(m).then((buf) => finish(buf.numberOfChannels)) + .catch(() => finish(2)); // unknown → stereo fallback + } ensureWave(m); reconcileAudioChannels(c); } @@ -2100,9 +2153,10 @@ function rebuildClips() { if (hasWave) body += ``; const badge = (c.keyframes && Object.keys(c.keyframes).length ? "◆ " : "") + (c.transitionIn || c.transitionOut ? "⇄ " : ""); - const chN = c.props?.audioChannel; - const chTag = chN === 0 ? "L · " : chN === 1 ? "R · " - : Number.isInteger(chN) ? `Ch${chN + 1} · ` : ""; + const chTag = (() => { + const s = audioChannelShort(c.props?.audioChannel); + return s ? s + " · " : ""; + })(); const label = c.kind === "text" ? "T · " + (c.props.text || "").split("\n")[0] : c.kind === "adjust" ? "FX · " + (c.name || "") : c.kind === "audio" ? chTag + (c.name || "") @@ -2986,9 +3040,7 @@ function renderInspector(lite) { `; } if (c.kind === "video" || c.kind === "audio") { - const chIdx = c.props?.audioChannel; - const chLabel = chIdx === 0 ? "Left" : chIdx === 1 ? "Right" - : Number.isInteger(chIdx) ? `Channel ${chIdx + 1}` : null; + const chLabel = audioChannelLong(c.props?.audioChannel); html += `

Audio / Time

${chLabel ? row("Channel", `${chLabel}`) : ""} ${slider("volume", 0, 2, 0.01, p.volume)} @@ -3414,21 +3466,15 @@ function ensureAudio() { } return runtime.audio; } -/** Route `src -> g -> (channel-isolated ->) out` on `ctx`. When `ch` is 0/1, - * `src` is split to stereo, only channel `ch` is fed through `g`, then - * re-merged onto the same side — used to isolate one stereo audio stem onto - * a single track bus. Returns the node to connect downstream (`g` when no - * isolation applies) plus the split/merge nodes (null when unused) so callers - * can track them for later disconnect/dispose. Shared by hookAudio, - * refreshAudioHold and the offline export mixdown. */ -function connectChannelIsolated(ctx, src, g, ch) { - if (ch === 0 || ch === 1) { - const split = ctx.createChannelSplitter(2); - const merge = ctx.createChannelMerger(2); - src.connect(split); - split.connect(g, ch); - g.connect(merge, 0, ch); - return { out: merge, split, merge }; +/** 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. */ +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 }; } src.connect(g); return { out: g, split: null, merge: null }; @@ -3441,11 +3487,13 @@ function hookAudio(c, el) { const src = ctx.createMediaElementSource(el); const g = ctx.createGain(); const ch = c.props?.audioChannel; - const { split, merge } = connectChannelIsolated(ctx, src, g, ch); - if (split) { - g._fcSplit = split; - g._fcOut = merge; - g._fcChannel = ch; + if (Number.isInteger(ch) && ch >= 0) { + const m = getMedia(c.mediaId); + const nCh = Math.max(m?.channels || 0, ch + 1, 2); + try { src.channelInterpretation = "discrete"; } catch { } + connectChannelIsolated(ctx, src, g, ch, nCh); + } else { + src.connect(g); } runtime.clipGain.set(c.id, g); routeClipGain(c); @@ -3825,7 +3873,8 @@ function refreshAudioHold() { const g = audio.ctx.createGain(); g.gain.value = vol; const ch = c.props?.audioChannel; - const { out, split, merge } = connectChannelIsolated(audio.ctx, src, g, ch); + 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 bus = audio.trackBus[c.track] || audio.master; out.connect(bus); const node = { src, gain: g, split, merge }; @@ -5294,7 +5343,9 @@ async function renderAudioMix(dur) { 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 ch = c.props?.audioChannel; - const { out } = connectChannelIsolated(off, src, g, ch); + 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 (hasSpeedRamp(c)) { const rc = new Float32Array(n); From f749d4d692cec27b32af41a1a7c33b5fa0327635 Mon Sep 17 00:00:00 2001 From: = <=> Date: Wed, 22 Jul 2026 19:49:48 +0300 Subject: [PATCH 02/11] feat: implement buttons for adding/removing av tracks --- CLAUDE.md | 31 ++++-- README.md | 3 +- app.js | 279 ++++++++++++++++++++++++++++++++++------------------- index.html | 8 +- style.css | 31 +++++- 5 files changed, 241 insertions(+), 111 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4065cc2..650d30c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,16 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`, { "id": "f_broll", "name": "B-roll", "parentId": null, "open": true } ], "disabledTracks": [ "A2" ], - // ^ optional — track ids (V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed + // ^ optional — track ids (e.g. V4 V3 V2 V1 A1 A2 A3) omitted from preview/export when listed + "tracks": [ // optional — timeline lanes (default V3…V1 + A1…A4) + { "id": "V3", "kind": "video" }, // video: higher number drawn on top (V1 under V2 under V3…) + { "id": "V2", "kind": "video" }, + { "id": "V1", "kind": "video" }, + { "id": "A1", "kind": "audio" }, // audio: A1…An top→bottom; +V/+A in the UI appends Vn+1 / An+1 + { "id": "A2", "kind": "audio" }, + { "id": "A3", "kind": "audio" }, + { "id": "A4", "kind": "audio" } + ], "media": [ { "id": "m_abc", "name": "intro.mp4", "kind": "video", // video|audio|image|svg "src": "/media/intro.mp4", // path under ./media or ./library @@ -178,12 +187,12 @@ Examples in `library/svg/`: `sparkles.svg` (loop), `lower-third.svg`, "id": "c_xyz", // unique string "mediaId": "m_abc", // null for kind:"text" and kind:"adjust" "kind": "video", // video | audio | image | svg | text | adjust - "track": "V1", // V3 V2 V1 (top→bottom video) | A1 A2 A3 A4 (audio) + "track": "V1", // Vn…V1 (video) | A1…An (audio); extras via UI +V/+A or tracks[] "start": 0, // timeline position, seconds "in": 2.5, // offset into source media, seconds (0 for image/svg/text) "duration": 5, // clip length on timeline, seconds "name": "intro", - "linkGroup": "lg_abc", // OPTIONAL — AV link: video + its L/R audio companions share one id + "linkGroup": "lg_abc", // OPTIONAL — AV link: video + per-channel audio stems share one id "props": { /* all optional — see the props reference below */ }, // OPTIONAL — keyframe animation. Times are seconds RELATIVE TO CLIP START. // "ease" sits on the DESTINATION keyframe of each segment: @@ -325,14 +334,16 @@ glitch (RGB split + jitter) · pop (overshoot scale — stickers/captions). ### Semantics -- Rendering order: V1 is drawn first, then V2, V3 on top. SVG/image/text clips - go on any V track (V2/V3 are handy overlay lanes). -- **Audio tracks A1–A4** hold both standalone audio files *and* linked companions +- 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. +- **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 stereo L/R `kind:"audio"` clips - on A1/A2 that share a `linkGroup` (and the same `mediaId` / timing). Standalone - music/SFX also live on A1–A4. Linked partners move/trim/split together — edit - timing on any member of the group; do not treat A-tracks as music-only. + (`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 + 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/ rotation → flips apply. - `props` keys are all optional — missing keys get the defaults above. diff --git a/README.md b/README.md index a31d19e..3111b5a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ same time. **Editing** -- 3 video tracks + 4 audio tracks, drag/trim/split/snap, undo/redo +- Video + audio tracks (default 3+4; add more with **+V** / **+A** in the track + header), 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 3a74178..a36e988 100644 --- a/app.js +++ b/app.js @@ -7,21 +7,31 @@ "use strict"; /* ── Constants ─────────────────────────────────────────────────────────── */ -const TRACKS = [ - { id: "V3", kind: "video", h: 44, color: "#ffd166" }, - { id: "V2", kind: "video", h: 58, color: "#7b6cff" }, - { id: "V1", kind: "video", h: 58, color: "#4f8cff" }, - { id: "A1", kind: "audio", h: 42, color: "#7ec249" }, - { id: "A2", kind: "audio", h: 42, color: "#5a9e3a" }, - { id: "A3", kind: "audio", h: 42, color: "#4a8a2f" }, - { id: "A4", kind: "audio", h: 42, color: "#3a7226" }, +const VIDEO_TRACK_COLORS = ["#4f8cff", "#7b6cff", "#ffd166", "#ff6b9d", "#45d9c2", "#f4a261", "#e76f51", "#a8dadc"]; +const AUDIO_TRACK_COLORS = ["#7ec249", "#5a9e3a", "#4a8a2f", "#3a7226", "#2d5a1e", "#8fbc5a", "#6b9e3a", "#4d7a28"]; +const MAX_TRACKS_PER_KIND = 16; +const DEFAULT_TRACK_DEFS = [ + { id: "V3", kind: "video" }, + { id: "V2", kind: "video" }, + { id: "V1", kind: "video" }, + { id: "A1", kind: "audio" }, + { id: "A2", kind: "audio" }, + { id: "A3", kind: "audio" }, + { id: "A4", kind: "audio" }, ]; +function makeTrack(id, kind) { + const n = Math.max(1, parseInt(String(id).slice(1), 10) || 1); + const colors = kind === "audio" ? AUDIO_TRACK_COLORS : VIDEO_TRACK_COLORS; + return { id, kind, h: kind === "audio" ? 42 : 58, color: colors[(n - 1) % colors.length] }; +} +/** Live track list (top→bottom). Mutated by +V/+A; rebuilt from project.tracks on load. */ +let TRACKS = DEFAULT_TRACK_DEFS.map((d) => makeTrack(d.id, d.kind)); /* Three timeline density presets. L matches the original track heights (with thumbs). S is compact solid-color rows; M is in between. */ const TRACK_SIZE_PRESETS = { - s: { thumbs: false, h: { V3: 26, V2: 26, V1: 26, A1: 22, A2: 22, A3: 22, A4: 22 } }, - m: { thumbs: true, h: { V3: 36, V2: 44, V1: 44, A1: 32, A2: 32, A3: 32, A4: 32 } }, - l: { thumbs: true, h: { V3: 44, V2: 58, V1: 58, A1: 42, A2: 42, A3: 42, A4: 42 } }, + s: { thumbs: false, hVideo: 26, hAudio: 22 }, + m: { thumbs: true, hVideo: 44, hAudio: 32 }, + l: { thumbs: true, hVideo: 58, hAudio: 42 }, }; // Generous cap on how many audio tracks can be auto-added for a multi-channel // source (e.g. 7.1 surround = 8 channels) — see ensureAudioTrackCount(). @@ -133,62 +143,101 @@ const ASPECT_PRESETS = [ ]; const WAVE_PEAKS_PER_SEC = 50; const TRACK_IDS = new Set(TRACKS.map((t) => t.id)); -// Audio lanes available for a video's per-channel linked audio (index = props.audioChannel). -// Starts as the 4 built-in tracks; ensureAudioTrackCount() grows both this and -// TRACKS/TRACK_IDS at runtime for sources with more channels (5.1, 7.1, …). -const AUDIO_TRACK_IDS = TRACKS.filter((t) => t.kind === "audio").map((t) => t.id); -/* 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); - const palette = ["#7ec249", "#5a9e3a", "#4a8a2f", "#3a7226"]; - const newIds = []; - while (AUDIO_TRACK_IDS.length < need) { - const n = AUDIO_TRACK_IDS.length + 1; - const id = "A" + n; - const preset = TRACK_SIZE_PRESETS[state.trackSize] || TRACK_SIZE_PRESETS.l; - TRACKS.push({ id, kind: "audio", h: preset.h.A1 || 42, color: palette[(n - 1) % palette.length] }); - TRACK_IDS.add(id); - AUDIO_TRACK_IDS.push(id); - newIds.push(id); - } - if (newIds.length) { - buildTrackDOM(); - if (runtime.audio) addLiveAudioTrackBuses(newIds); +function syncTrackIds() { + TRACK_IDS.clear(); + for (const t of TRACKS) TRACK_IDS.add(t.id); +} +function serializeTracks() { + return TRACKS.map(({ id, kind }) => ({ id, kind })); +} +function sortTracksInPlace() { + const vids = TRACKS.filter((t) => t.kind === "video") + .sort((a, b) => (parseInt(b.id.slice(1), 10) || 0) - (parseInt(a.id.slice(1), 10) || 0)); + const auds = TRACKS.filter((t) => t.kind === "audio") + .sort((a, b) => (parseInt(a.id.slice(1), 10) || 0) - (parseInt(b.id.slice(1), 10) || 0)); + TRACKS.length = 0; + TRACKS.push(...vids, ...auds); + syncTrackIds(); +} +function applyTracksFromProject(defs) { + const list = Array.isArray(defs) && defs.length + ? defs.filter((d) => d && d.id && (d.kind === "video" || d.kind === "audio")) + : DEFAULT_TRACK_DEFS; + TRACKS.length = 0; + for (const d of list) TRACKS.push(makeTrack(d.id, d.kind === "audio" ? "audio" : "video")); + sortTracksInPlace(); + applyTrackHeights(); +} +/** Ensure every clip.track exists (agents may reference V4+ before the UI adds it). */ +function ensureTracksCoverClips() { + let added = false; + for (const c of project.clips) { + 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)); + added = true; + } + if (added) { + sortTracksInPlace(); + applyTrackHeights(); } - return newIds.length; } -/* Wire newly-added tracks into an already-running audio graph (playback may - have started before a multi-channel replace/add grew the track set). All - buses are created up front, then the meter is reinstalled at most once — - its AudioWorkletNode input count is fixed at creation, so adding several - tracks in one go (e.g. 4 new lanes for a 7.1 source) must snapshot the - full updated track list before rebuilding it, not one track at a time. */ -function addLiveAudioTrackBuses(ids) { - const audio = runtime.audio; - if (!audio) return; - for (const id of ids) { - if (audio.trackBus[id]) continue; - audio.trackBus[id] = audio.ctx.createGain(); - audio.audioTrackIds.push(id); +function audioTrackIds() { + return TRACKS.filter((t) => t.kind === "audio").map((t) => t.id); +} +function nextTrackId(kind) { + const prefix = kind === "audio" ? "A" : "V"; + let max = 0; + for (const t of TRACKS) { + if (t.kind !== kind) continue; + const n = parseInt(t.id.slice(1), 10); + if (n > max) max = n; + } + return prefix + (max + 1); +} +function addTimelineTrack(kind) { + const existing = TRACKS.filter((t) => t.kind === kind).length; + if (existing >= MAX_TRACKS_PER_KIND) { + toast(`Maximum ${MAX_TRACKS_PER_KIND} ${kind} tracks`); + return null; + } + const id = nextTrackId(kind); + const t = makeTrack(id, kind); + TRACKS.push(t); + sortTracksInPlace(); + applyTrackHeights(); + project.tracks = serializeTracks(); + if (kind === "audio") syncAudioGraphTracks(); + buildTrackDOM(); + syncAllTrackDisabledUI(); + state.dirtyTimeline = true; + rebuildClips(); + const h = setTimelineHeight(Math.max( + $("timelinePanel")?.getBoundingClientRect().height || 0, + defaultTimelineHeight() + )); + localStorage.setItem(TL_H_KEY, String(h)); + 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 (!audio.meterReady) { - for (const id of ids) audio.trackBus[id]?.connect(audio.master); - return; + if (added) { + sortTracksInPlace(); + applyTrackHeights(); + project.tracks = serializeTracks(); + buildTrackDOM(); + if (runtime.audio) syncAudioGraphTracks(); } - // Feed every bus (old + new) through master directly before tearing down - // the old meter — if the rebuild below fails, all tracks stay audible via - // this fallback instead of feeding an orphaned, disconnected meter node. - for (const id of Object.keys(audio.trackBus)) { - try { audio.trackBus[id].disconnect(); } catch {} - audio.trackBus[id].connect(audio.master); - } - try { audio.meter.port.onmessage = null; } catch {} - try { audio.meter.disconnect(); } catch {} - audio.meter = null; - audio.meterReady = false; - installMeterWorklet(audio).catch(() => {}); + return added; } /* ── User settings (localStorage; optional behavior toggles) ── */ @@ -234,7 +283,8 @@ const project = { markers: [], // {t, label?} — beat/cue markers on the ruler; snap targets inPoint: null, // timeline work-area IN (seconds), or null outPoint: null, // timeline work-area OUT (seconds), or null - disabledTracks: [], // track ids (V4…A3) hidden from preview/export when listed + disabledTracks: [], // track ids omitted from preview/export when listed + tracks: null, // optional [{id, kind}] — null means default V3…V1 + A1…A4 }; const state = { time: 0, playing: false, pps: 60, snap: true, @@ -570,7 +620,6 @@ function startFolderRename(folderId) { } function applyProject(data) { const wa = normalizeWorkArea(data.inPoint, data.outPoint); - const disabledTracks = normalizeDisabledTracks(data.disabledTracks); Object.assign(project, { name: data.name || "Untitled Project", width: data.width || 1280, height: data.height || 720, fps: data.fps || 30, @@ -582,8 +631,14 @@ function applyProject(data) { markers: (data.markers || []).filter((m) => m && isFinite(m.t)).sort((a, b) => a.t - b.t), inPoint: wa.inPoint, outPoint: wa.outPoint, - disabledTracks, + disabledTracks: [], + tracks: null, }); + applyTracksFromProject(data.tracks); + ensureTracksCoverClips(); + project.tracks = serializeTracks(); + const disabledTracks = normalizeDisabledTracks(data.disabledTracks); + project.disabledTracks = disabledTracks; const folderIds = new Set(project.folders.map((f) => f.id)); for (const m of project.media) { if (m.folderId && !folderIds.has(m.folderId)) m.folderId = null; @@ -595,15 +650,6 @@ function applyProject(data) { if (Array.isArray(arr)) arr.sort((a, b) => a.t - b.t); if (c.kind === "text") ensureFont(c.props.font); } - // TRACKS isn't persisted — any extra audio lanes (A5+, from a multi-channel - // source) only exist as clip.track references on disk. Recreate them so - // those clips don't silently vanish from the timeline on reload. - let maxAudioTrack = AUDIO_TRACK_IDS.length; - for (const c of project.clips) { - const am = /^A(\d+)$/.exec(c.track || ""); - if (am) maxAudioTrack = Math.max(maxAudioTrack, +am[1]); - } - if (maxAudioTrack > AUDIO_TRACK_IDS.length) ensureAudioTrackCount(maxAudioTrack); // 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 @@ -611,10 +657,12 @@ function applyProject(data) { else stopAudioHoldNodes(); for (const el of runtime.clipEls.values()) { try { el.pause(); el.src = ""; } catch { } } runtime.clipEls.clear(); runtime.clipGain.clear(); + if (runtime.audio) syncAudioGraphTracks(); els.preview.width = project.width; els.preview.height = project.height; els.monitorRes.textContent = `${project.width} × ${project.height} · ${project.fps}fps`; syncAspectSel(); pruneSelection(); // keep the selection across external reloads where possible + buildTrackDOM(); state.dirtyTimeline = true; renderBin(); renderInspector(); updateWorkArea(); @@ -645,6 +693,7 @@ function projectJSON() { name, width, height, fps, background, revision, folders: (folders || []).map(({ id, name, parentId, open }) => ({ id, name, parentId: parentId || null, open: open !== false })), + tracks: serializeTracks(), media: media.filter((m) => !m.transient).map(({ id, name, kind, src, duration, width, height, folderId }) => ({ id, name, kind, src, duration, width, height, folderId: folderId || null })), clips: clips.map(({ id, mediaId, kind, track, start, in: inn, duration, name, props, keyframes, transitionIn, transitionOut, linkedId, linkGroup }) => { @@ -1289,19 +1338,20 @@ function audioChannelLong(ch) { /** How many linked A-track stems we can create for a media item. */ function linkedAudioChannelCount(m) { const n = Math.max(1, m.channels | 0); - return Math.min(n, AUDIO_TRACK_IDS.length); + return Math.min(n, audioTrackIds().length); } -/** Attach one audio clip per source channel (A1…A4), sharing the video's linkGroup. */ +/** 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 []; const lg = videoClip.linkGroup; // Drop any prior stems for this group (e.g. stereo placeholder → 3.0 upgrade). project.clips = project.clips.filter((x) => !(x.linkGroup === lg && x.kind === "audio")); - const n = Math.min(Math.max(1, nCh | 0), AUDIO_TRACK_IDS.length); + const ids = audioTrackIds(); + const n = Math.min(Math.max(1, nCh | 0), ids.length); const out = []; for (let ch = 0; ch < n; ch++) { const a = { - id: "c_" + uid(), mediaId: m.id, kind: "audio", track: AUDIO_TRACK_IDS[ch], + 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 }, @@ -1343,7 +1393,7 @@ function addClipFromMedia(m, trackId, at) { props: { ...DEFAULT_PROPS }, }; project.clips.push(c); - // Video+audio: picture on a V track; one linked stem per source channel on A1…A4. + // Video+audio: picture on a V track; one linked stem per source channel on A-tracks. // Mute the video clip so audio isn't doubled. if (kind === "video") { c.props.volume = 0; @@ -1396,10 +1446,11 @@ async function reconcileAudioChannels(videoClip) { const live = getClip(videoClip.id); if (!live || live.mediaId !== mediaId) return; // superseded by a newer add/replace const lg = live.linkGroup; - const tracksBefore = AUDIO_TRACK_IDS.length; + const tracksBefore = audioTrackIds().length; if (chCount > tracksBefore) ensureAudioTrackCount(chCount); - const newTracks = AUDIO_TRACK_IDS.length - tracksBefore; - const wantCh = Math.min(chCount, AUDIO_TRACK_IDS.length); + const ids = audioTrackIds(); + const newTracks = ids.length - tracksBefore; + const wantCh = Math.min(chCount, ids.length); const have = project.clips.filter((c) => c.linkGroup === lg && c.kind === "audio"); let added = 0, removed = 0; for (const c of have) { @@ -1412,7 +1463,7 @@ async function reconcileAudioChannels(videoClip) { for (let ch = 0; ch < wantCh; ch++) { if (have.some((c) => c.props?.audioChannel === ch)) continue; project.clips.push({ - id: "c_" + uid(), mediaId, kind: "audio", track: AUDIO_TRACK_IDS[ch], + 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 }, linkGroup: lg, @@ -1422,7 +1473,7 @@ async function reconcileAudioChannels(videoClip) { if (!added && !removed) return; state.dirtyTimeline = true; scheduleSave(); renderInspector(); - if (chCount > AUDIO_TRACK_IDS.length) + if (chCount > ids.length) toast(`${m.name}: ${chCount} audio channels, only ${MAX_AUDIO_TRACKS} tracks supported — extra channel(s) dropped`); else if (added) toast(newTracks @@ -2024,11 +2075,14 @@ function trackToggleIcon(kind) { ``; } function buildTrackDOM() { - els.trackHeaders.innerHTML = ""; + let inner = $("trackHeadInner"); + if (!inner) { + inner = document.createElement("div"); + inner.id = "trackHeadInner"; + els.trackHeaders.appendChild(inner); + } + inner.innerHTML = ""; els.tracks.innerHTML = ""; - const inner = document.createElement("div"); - inner.id = "trackHeadInner"; - els.trackHeaders.appendChild(inner); for (const t of TRACKS) { const on = isTrackEnabled(t.id); const h = document.createElement("div"); @@ -2856,6 +2910,8 @@ function zoomToRange(t0, t1) { } els.zoomSlider.addEventListener("input", () => setZoom(+els.zoomSlider.value)); $("btnZoomFit").addEventListener("click", zoomToFit); +$("btnAddV").addEventListener("click", () => addTimelineTrack("video")); +$("btnAddA").addEventListener("click", () => addTimelineTrack("audio")); els.timelineScroll.addEventListener("wheel", (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); @@ -3443,10 +3499,10 @@ function ensureAudio() { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const master = ctx.createGain(); const recDest = ctx.createMediaStreamDestination(); - const audioTracks = TRACKS.filter((t) => t.kind === "audio"); + const ids = audioTrackIds(); const trackBus = {}; - for (const t of audioTracks) { - trackBus[t.id] = ctx.createGain(); + for (const id of ids) { + trackBus[id] = ctx.createGain(); } // Until the worklet is ready, audio-track buses and master both feed speakers. master.connect(ctx.destination); @@ -3456,7 +3512,7 @@ function ensureAudio() { } runtime.audio = { ctx, master, recDest, trackBus, - audioTrackIds: audioTracks.map((t) => t.id), + audioTrackIds: ids.slice(), meter: null, meterReady: false, }; installMeterWorklet(runtime.audio).catch(() => {}); @@ -3479,6 +3535,38 @@ function connectChannelIsolated(ctx, src, g, ch, nCh) { src.connect(g); return { out: g, split: null, merge: null }; } +/** Create missing A-track buses and rebuild the meter when the track list changes. */ +function syncAudioGraphTracks() { + const audio = runtime.audio; + if (!audio) return; + const ids = audioTrackIds(); + for (const id of ids) { + if (!audio.trackBus[id]) { + const g = audio.ctx.createGain(); + audio.trackBus[id] = g; + g.connect(audio.master); + } + } + 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; + } + installMeterWorklet(audio).catch(() => {}); + // Re-route clip gains onto (possibly new) buses + for (const c of project.clips) { + if (c.kind === "audio" || c.kind === "video") routeClipGain(c); + } +} function hookAudio(c, el) { if (!runtime.audio || runtime.clipGain.has(c.id)) return; if (c.kind !== "video" && c.kind !== "audio") return; @@ -5960,10 +6048,7 @@ function syncTrackSizeButtons() { function applyTrackHeights() { const preset = TRACK_SIZE_PRESETS[state.trackSize] || TRACK_SIZE_PRESETS.l; for (const t of TRACKS) { - // tracks beyond the static set (e.g. A5+, auto-added for >4-channel audio) - // aren't named in the preset map — size them like the first track of their kind. - const h = preset.h[t.id] ?? preset.h[t.kind === "audio" ? "A1" : "V1"]; - if (h != null) t.h = h; + t.h = t.kind === "audio" ? preset.hAudio : preset.hVideo; } } /* Switch S/M/L track density, rebuild the timeline, and grow/shrink the pane diff --git a/index.html b/index.html index bfbd1b1..ae24deb 100644 --- a/index.html +++ b/index.html @@ -135,7 +135,13 @@
-
+
+
+ + +
+
+
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+TAdd transition at playhead (in / out by half) DelDelete selected clip(s), or clear focused transition Drag empty areaMarquee-select clips + Right-click trackRemove empty track (keeps ≥1 video + ≥1 audio) Ctrl+click binImport media files Ctrl+click clipAdd / remove from selection Ctrl+A / EscSelect 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

DelDelete selected clip(s), or clear focused transition Drag empty areaMarquee-select clips Right-click trackRemove empty track (keeps ≥1 video + ≥1 audio) + Track SSolo track (mute all others; click again to restore) Ctrl+click binImport media files Ctrl+click clipAdd / remove from selection Ctrl+A / EscSelect 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 instead of METER_SEGS separate @@ -3888,6 +3886,66 @@ function resetMasterMeterBallistics() { meterState.lastLit[MASTER_METER_L] = meterState.lastLit[MASTER_METER_R] = -1; meterState.lastHold[MASTER_METER_L] = meterState.lastHold[MASTER_METER_R] = -1; } +/** Tap the post-meter stereo bus for true L/R master readings (matches headphones). */ +function disposeMasterAnalysers(audio) { + if (!audio?.masterSplit) return; + try { audio.meter?.disconnect(audio.masterSplit); } catch {} + try { audio.masterSplit.disconnect(); } catch {} + audio.masterSplit = null; + audio.masterAnalysers = null; +} +/** Disconnect meter + analysers; restore buses→master→speakers fallback. */ +function teardownMeterNode(audio) { + if (!audio) return; + disposeMasterAnalysers(audio); + if (audio.meter) { + try { audio.meter.port.onmessage = null; } catch {} + try { audio.meter.disconnect(); } catch {} + } + try { audio.master.disconnect(); } catch {} + try { audio.master.connect(audio.ctx.destination); } catch {} + try { audio.master.connect(audio.recDest); } catch {} +} +function installMasterAnalysers(audio, meterNode) { + disposeMasterAnalysers(audio); + const meter = meterNode || audio?.meter; + if (!meter) return; + const ctx = audio.ctx; + const split = ctx.createChannelSplitter(2); + const aL = ctx.createAnalyser(); + const aR = ctx.createAnalyser(); + const n = 2048; + aL.fftSize = n; + aR.fftSize = n; + aL.smoothingTimeConstant = 0; + aR.smoothingTimeConstant = 0; + meter.connect(split); + split.connect(aL, 0); + split.connect(aR, 1); + audio.masterSplit = split; + audio.masterAnalysers = { L: aL, R: aR, bufL: new Float32Array(n), bufR: new Float32Array(n) }; +} +function sampleMasterAnalysers() { + const a = runtime.audio?.masterAnalysers; + if (!a) return; + a.L.getFloatTimeDomainData(a.bufL); + a.R.getFloatTimeDomainData(a.bufR); + let sumL = 0, sumR = 0, pkL = 0, pkR = 0; + const n = a.bufL.length; + for (let i = 0; i < n; i++) { + const l = a.bufL[i], r = a.bufR[i]; + sumL += l * l; + sumR += r * r; + const al = l >= 0 ? l : -l, ar = r >= 0 ? r : -r; + if (al > pkL) pkL = al; + if (ar > pkR) pkR = ar; + } + const inv = 1 / Math.max(1, n); + meterState.master.rmsL = Math.sqrt(sumL * inv); + meterState.master.rmsR = Math.sqrt(sumR * inv); + meterState.master.peakL = pkL; + meterState.master.peakR = pkR; +} const meterState = { mode: (() => { try { @@ -3906,6 +3964,11 @@ const meterState = { lastLit: {}, // id -> last-painted lit/hold seg indices, to skip redundant DOM writes lastHold: {}, modeBtn: null, + tracksToggleBtn: null, + tracksExpanded: (() => { + try { return localStorage.getItem("fablecut-meter-tracks-expanded") !== "0"; } + catch { return true; } + })(), 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, @@ -3930,55 +3993,85 @@ function cycleMeterMode(ev) { } resetMasterMeterBallistics(); } +function syncMeterTracksExpandedUI() { + const root = $("vuMeter"); + if (!root) return; + root.classList.toggle("vu-tracks-open", meterState.tracksExpanded); + const btn = meterState.tracksToggleBtn; + if (btn) { + btn.textContent = meterState.tracksExpanded ? "▸" : "◂"; + btn.title = meterState.tracksExpanded ? "Hide track meters" : "Show track meters"; + btn.setAttribute("aria-expanded", meterState.tracksExpanded ? "true" : "false"); + } + if (meterState.tracksExpanded) { + for (const id of meterState.trackIds) { + meterState.lastLit[id] = -1; + meterState.lastHold[id] = -1; + } + } +} +function toggleMeterTracksExpanded(ev) { + if (ev) { ev.preventDefault(); ev.stopPropagation(); } + meterState.tracksExpanded = !meterState.tracksExpanded; + try { localStorage.setItem("fablecut-meter-tracks-expanded", meterState.tracksExpanded ? "1" : "0"); } catch {} + syncMeterTracksExpandedUI(); +} async function installMeterWorklet(audio) { - if (audio.meterReady || !audio.ctx.audioWorklet || meterState._loading) return; + if (audio.meterReady || !audio.ctx.audioWorklet) return; + if (meterState._loading) { + meterState._reloadMeter = true; // sync tore down mid-install — retry in finally + return; + } meterState._loading = true; + meterState._reloadMeter = false; const trackIds = audio.audioTrackIds.slice(); const nAudio = trackIds.length; const nInputs = Math.max(1, nAudio + 1); // +1 = video/other spill on master + let meter = null; try { - await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=3"); - const meter = new AudioWorkletNode(audio.ctx, "fablecut-meter", { + await audio.ctx.audioWorklet.addModule("meter-worklet.js?v=7"); + // Aborted by syncAudioGraphTracks while we were loading — retry fresh. + if (meterState._reloadMeter) return; + meter = new AudioWorkletNode(audio.ctx, "fablecut-meter", { numberOfInputs: nInputs, numberOfOutputs: 1, outputChannelCount: [2], channelCount: 2, channelCountMode: "explicit", + channelInterpretation: "discrete", processorOptions: { hopBlocks: 8, nTracks: nInputs, nAudioTracks: nAudio, trackIds }, }); meter.port.onmessage = (ev) => { const msg = ev.data; if (!msg || msg.type !== "meter") return; - const ids = msg.trackIds || trackIds; - for (let i = 0; i < ids.length; i++) { - const id = ids[i]; + for (let i = 0; i < nAudio; i++) { + const id = trackIds[i]; + if (!id) continue; meterState.rms[id] = msg.rms[i] || 0; 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; - } }; - // Full mix: A-buses + master spill → meter → speakers/rec (single summed path) - for (const id of trackIds) { - const bus = audio.trackBus[id]; + // Connect outputs first so a wiring error never leaves the graph silent. + meter.connect(audio.ctx.destination); + meter.connect(audio.recDest); + + // Full mix: A-buses + master spill → meter (single summed path). + for (let i = 0; i < trackIds.length; i++) { + const bus = audio.trackBus[trackIds[i]]; + if (!bus) continue; try { bus.disconnect(); } catch {} - bus.connect(meter, 0, trackIds.indexOf(id)); + bus.connect(meter, 0, i); } try { audio.master.disconnect(audio.ctx.destination); } catch {} try { audio.master.disconnect(audio.recDest); } catch {} audio.master.connect(meter, 0, nAudio); - meter.connect(audio.ctx.destination); - meter.connect(audio.recDest); + + installMasterAnalysers(audio, meter); audio.meter = meter; + meter = null; // ownership transferred — catch must not tear down live node audio.meterReady = true; meterState.trackIds = trackIds; for (const id of trackIds) { @@ -3993,8 +4086,26 @@ async function installMeterWorklet(audio) { buildMeterDOM(); } catch (err) { console.warn("[FableCut] meter worklet unavailable:", err); + if (meter) { + try { meter.port.onmessage = null; } catch {} + try { meter.disconnect(); } catch {} + } + // Buses were disconnected above — restore direct master path. + for (const id of trackIds) { + const bus = audio.trackBus[id]; + if (!bus) continue; + try { bus.disconnect(); } catch {} + try { bus.connect(audio.master); } catch {} + } + try { audio.master.disconnect(); } catch {} + try { audio.master.connect(audio.ctx.destination); } catch {} + try { audio.master.connect(audio.recDest); } catch {} } finally { meterState._loading = false; + if (meterState._reloadMeter && !audio.meterReady) { + meterState._reloadMeter = false; + installMeterWorklet(audio).catch(() => {}); + } } } function buildMeterDOM() { @@ -4002,31 +4113,13 @@ function buildMeterDOM() { if (!root) return; const tracks = audioMeterTracks(); root.innerHTML = ""; - root.title = `Mode: ${METER_MODE_LABEL[meterState.mode]} — click to switch`; - - const modeBtn = document.createElement("button"); - modeBtn.type = "button"; - modeBtn.className = "vu-mode"; - modeBtn.textContent = METER_MODE_LABEL[meterState.mode]; - modeBtn.title = "Cycle RMS → LUFS → Peak"; - modeBtn.addEventListener("click", cycleMeterMode); - root.appendChild(modeBtn); - meterState.modeBtn = modeBtn; + root.classList.toggle("vu-tracks-open", meterState.tracksExpanded); const row = document.createElement("div"); row.className = "vu-channels"; - const scale = document.createElement("div"); - scale.className = "vu-scale"; - const span = METER_DB_MAX - METER_DB_MIN; - for (const db of METER_DB_MARKS) { - const tick = document.createElement("span"); - tick.className = "vu-scale-tick"; - tick.textContent = db === 0 ? "0" : String(db); - tick.style.top = ((METER_DB_MAX - db) / span * 100) + "%"; - scale.appendChild(tick); - } - row.appendChild(scale); + const tracksWrap = document.createElement("div"); + tracksWrap.className = "vu-tracks-wrap"; meterState.segs = {}; meterState.lastLit = {}; @@ -4055,9 +4148,46 @@ function buildMeterDOM() { label.textContent = t.id; col.appendChild(segsCv); col.appendChild(label); - row.appendChild(col); + tracksWrap.appendChild(col); + } + row.appendChild(tracksWrap); + + const scaleMaster = document.createElement("div"); + scaleMaster.className = "vu-scale-master"; + + const scale = document.createElement("div"); + scale.className = "vu-scale"; + for (const db of METER_DB_MARKS) { + const tick = document.createElement("span"); + tick.className = "vu-scale-tick"; + tick.textContent = db === 0 ? "0" : String(db); + tick.style.top = meterMarkTopPct(db) + "%"; + scale.appendChild(tick); } + const masterStack = document.createElement("div"); + masterStack.className = "vu-master-stack"; + + if (tracks.length) { + const tracksBtn = document.createElement("button"); + tracksBtn.type = "button"; + tracksBtn.className = "vu-tracks-toggle"; + tracksBtn.addEventListener("click", toggleMeterTracksExpanded); + masterStack.appendChild(tracksBtn); + meterState.tracksToggleBtn = tracksBtn; + } else { + meterState.tracksToggleBtn = null; + } + + const modeBtn = document.createElement("button"); + modeBtn.type = "button"; + modeBtn.className = "vu-mode"; + modeBtn.textContent = METER_MODE_LABEL[meterState.mode]; + modeBtn.title = "Cycle RMS → LUFS → Peak"; + modeBtn.addEventListener("click", cycleMeterMode); + masterStack.appendChild(modeBtn); + meterState.modeBtn = modeBtn; + const masterWrap = document.createElement("div"); masterWrap.className = "vu-master"; for (const ch of ["L", "R"]) { @@ -4077,8 +4207,13 @@ function buildMeterDOM() { col.appendChild(label); masterWrap.appendChild(col); } - row.appendChild(masterWrap); + masterStack.appendChild(masterWrap); + scaleMaster.appendChild(masterStack); + scaleMaster.appendChild(scale); + row.appendChild(scaleMaster); + if (!tracks.length) masterStack.classList.add("vu-master-only"); root.appendChild(row); + syncMeterTracksExpandedUI(); } function rmsToDb(rms) { return rms > 1e-8 ? 20 * Math.log10(rms) : METER_DB_MIN; @@ -4090,8 +4225,8 @@ function meterReadingDb(id) { 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); + // Per-channel level for stereo master bars (worklet stereo LUFS is one combined value). + return rmsToDb(isL ? m.rmsL : m.rmsR); } return rmsToDb(isL ? m.rmsL : m.rmsR); } @@ -4139,6 +4274,8 @@ function updateMeterChannel(id, target, dt, attack, release, now) { const segs = meterState.segs[id]; if (!segs) return; + const isMaster = id === MASTER_METER_L || id === MASTER_METER_R; + if (!isMaster && !meterState.tracksExpanded) 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( @@ -4150,8 +4287,8 @@ function updateMeterChannel(id, target, dt, attack, release, now) { paintMeterSegs(segs, lit, hold); } function updateMeterUI(dt) { - const ids = meterState.trackIds; const metering = (state.playing || state.audioHold) && runtime.audio?.meterReady; + if (metering) sampleMasterAnalysers(); const mode = meterState.mode; // Peak: snappy; LUFS already smoothed in-worklet (400 ms); RMS: classic VU feel const atkMs = mode === "peak" ? 0.005 : mode === "lufs" ? 0.04 : 0.015; @@ -4160,7 +4297,7 @@ function updateMeterUI(dt) { const release = 1 - Math.exp(-dt / relMs); const now = performance.now(); const floor = METER_DB_MIN; - for (const id of [...ids, ...MASTER_METER_IDS]) { + for (const id of [...meterState.trackIds, ...MASTER_METER_IDS]) { const target = metering ? meterReadingDb(id) : floor; updateMeterChannel(id, target, dt, attack, release, now); } @@ -4268,12 +4405,12 @@ 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 { split } = connectChannelIsolated(audio.ctx, src, g, ch, nCh); + const panner = audio.ctx.createStereoPanner(); + panner.pan.value = clipPan(p.pan); + g.connect(panner); const bus = audio.trackBus[c.track] || audio.master; panner.connect(bus); const node = { src, gain: g, panner, split }; diff --git a/meter-worklet.js b/meter-worklet.js index cf82b5a..47fc5f1 100644 --- a/meter-worklet.js +++ b/meter-worklet.js @@ -1,7 +1,7 @@ -/* FableCut per-track + stereo master meter worklet. +/* FableCut per-track meter worklet + stereo program sum pass-through. 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) { + Pass-through sum → stereo out. Per-track RMS/LUFS/Peak via port; master L/R + is metered on the main thread (AnalyserNodes on the worklet output). */function shelfCoeffs(fs) { const f0 = 1681.974450955533; const G = 3.999843853973347; const Q = 0.7071752369554196; @@ -47,7 +47,6 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { 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); this._peak = new Float64Array(this._nTracks); @@ -67,7 +66,8 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { this._hpfR.push(makeBiquad(hpf)); } - // Momentary = 400 ms of block mean-squares (BS.1770) + // Momentary = 400 ms of block mean-squares (BS.1770) — per A-track only; + // master L/R is sampled via AnalyserNodes on the post-meter output in app.js. const hopFrames = 128 * this._hopBlocks; this._lufsLen = Math.max(1, Math.round(0.4 * sampleRate / hopFrames)); this._lufsRing = []; @@ -78,35 +78,23 @@ 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) { const output = outputs[0]; const outL = output && output[0]; - const outR = output && (output[1] || output[0]); + const outR = output && output[1]; if (outL) outL.fill(0); - if (outR && outR !== outL) outR.fill(0); + if (outR) outR.fill(0); let frames = 0; for (let t = 0; t < this._nTracks; t++) { const chans = inputs[t]; const has = chans && chans[0]; const L = has ? chans[0] : null; - const R = has ? (chans[1] || chans[0]) : null; + // Never mirror L→R: post-pan buses are stereo; mono input = one channel only. + const hasR = has && chans.length > 1 && chans[1]; + const R = hasR ? chans[1] : null; const n = L ? L.length : (outL ? outL.length : 128); frames = n; @@ -119,7 +107,7 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { for (let i = 0; i < n; i++) { const l = L ? L[i] : 0; const r = R ? R[i] : 0; - const mono = (l + r) * 0.5; + const mono = R ? (l + r) * 0.5 : l; sum += mono * mono; const aL = l >= 0 ? l : -l; const aR = r >= 0 ? r : -r; @@ -131,41 +119,13 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { sumK += fl * fl + fr * fr; if (outL) outL[i] += l; - if (outR && outR !== outL) outR[i] += r; + if (outR) outR[i] += r; } this._sumSq[t] = sum; this._peak[t] = peak; 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++; @@ -196,35 +156,11 @@ class FableCutMeterProcessor extends AudioWorkletProcessor { 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 9b07b60..9bc17d3 100644 --- a/style.css +++ b/style.css @@ -842,7 +842,7 @@ body.track-size-s .clip.selected { /* ── Per-track VU (RMS / LUFS / Peak) ── */ .vu-meter { position: absolute; - right: 8px; + right: 2px; bottom: 10px; display: flex; flex-direction: column; @@ -850,15 +850,60 @@ body.track-size-s .clip.selected { gap: 4px; z-index: 4; pointer-events: auto; - padding: 5px 5px 4px; + padding: 5px 2px 4px 5px; background: #000a; user-select: none; } +.vu-channels { + display: flex; + align-items: flex-end; + gap: 0; +} +.vu-tracks-wrap { + display: none; + align-items: flex-end; + gap: 2px; +} +.vu-meter.vu-tracks-open .vu-tracks-wrap { display: flex; } +.vu-scale-master { + display: flex; + align-items: flex-end; + gap: 0; +} +.vu-master-stack { + display: grid; + grid-template-columns: auto 30px; + grid-template-rows: auto auto; + column-gap: 0; + row-gap: 1px; +} +.vu-tracks-toggle { + grid-row: 1; + grid-column: 1; + align-self: center; + display: flex; + align-items: center; + justify-content: center; + width: 10px; + height: 14px; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: #a8a8b0; + font: 700 14px/1 ui-sans-serif, system-ui, sans-serif; + cursor: pointer; + opacity: .9; +} +.vu-tracks-toggle:hover { opacity: 1; color: #fff; } .vu-mode { - display: block; - width: 100%; + grid-row: 1; + grid-column: 2; + box-sizing: border-box; + width: 30px; + justify-self: center; margin: 0; - padding: 2px 0 2px 14px; + padding: 0; border: 0; background: transparent; color: #c8c8d0; @@ -867,19 +912,29 @@ body.track-size-s .clip.selected { text-align: center; cursor: pointer; opacity: .85; + white-space: nowrap; + overflow: hidden; } .vu-mode:hover { opacity: 1; color: #fff; } -.vu-channels { +.vu-master { + grid-row: 2; + grid-column: 2; + box-sizing: border-box; + width: 30px; + justify-self: center; display: flex; - align-items: flex-start; - gap: 2px; + gap: 3px; + justify-content: center; + padding-left: 0; + margin-left: 0; + border-left: none; } .vu-scale { position: relative; /* Match .vu-segs height: 16×12px + 15×2px gap */ width: 16px; height: 222px; - margin-right: 0px; + flex-shrink: 0; pointer-events: none; } .vu-scale-tick { @@ -893,12 +948,12 @@ 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-stack.vu-master-only { + grid-template-columns: 30px; +} +.vu-master-stack.vu-master-only .vu-mode, +.vu-master-stack.vu-master-only .vu-master { + grid-column: 1; } .vu-master .vu-label { color: #d4d4dc; } .vu-channel {