From 5aa424c1cf7cd4e48343813f66e9f5949ab883cb Mon Sep 17 00:00:00 2001 From: Fillip Date: Tue, 4 Aug 2026 21:25:25 -0700 Subject: [PATCH] Add live beat offset control --- README.md | 2 +- nodes/audio/FL_Audio_Beat_Prompt_Schedule.py | 44 +++++- nodes/audio/audio_timeline.py | 52 +++++-- pyproject.toml | 2 +- tests/test_audio_beat_prompt_schedule.py | 21 +++ tests/test_audio_timeline.py | 63 ++++++++ .../audio/FL_Audio_Beat_Prompt_Schedule.js | 138 ++++++++++++++++-- 7 files changed, 291 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 3d25204..9ea1a56 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ The camera pushes forward on the beat. Frame ranges are zero-based and range ends are exclusive. Detected beats appear in their own marker lane and can be used as the editor's snap target without changing the stored frame timing. A connected prompt schedule overrides the manual timeline. Exact repeated prompts share one conditioning mask, so reuse prompts for recurring sections such as choruses. -The prompt schedule node loads its waveform as soon as an audio file is selected; queueing is not required. Use the source overview handles to trim, Play/Stop for crop-aligned transport, and the main frame ruler to drag or resize prompt clips. Beat Grid, Detected Beat, Onset, Frame, and Off are distinct snap modes. FPS and length remain normal node widgets, and length is always a frame count. Analysis is cached by source hash, crop, FPS, and detector settings. +The prompt schedule node loads its waveform as soon as an audio file is selected; queueing is not required. Use the source overview handles to trim, Play/Stop for crop-aligned transport, and the main frame ruler to drag or resize prompt clips. Beat Grid, Detected Beat, Onset, Frame, and Off are distinct snap modes. The live Beat offset slider moves the regular and detected beat markers, updates snapping immediately, and leaves the waveform, audio, onsets, drums, and prompt clips fixed. FPS and length remain normal node widgets, and length is always a frame count. Analysis is cached by source hash, crop, FPS, and detector settings; offset changes reuse the same detected timing. `Separate stems` is an explicit action and never runs automatically. It separates the full source once with Hybrid Demucs, caches bass/drums/other/vocals locally, switches analysis to the drums stem, and keeps the node's audio output on the original master crop. Jobs report progress, can be cancelled between chunks, and reuse valid cached stems. diff --git a/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py b/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py index 861d18a..e0e191c 100644 --- a/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py +++ b/nodes/audio/FL_Audio_Beat_Prompt_Schedule.py @@ -9,7 +9,7 @@ from .audio_files import ( available_audio_files, resolve_audio_path, ) -from .audio_timeline import analyze_audio_file +from .audio_timeline import analyze_audio_file, apply_beat_offset FLPromptSchedule = io.Custom("FL_PROMPT_SCHEDULE") @@ -41,11 +41,17 @@ def _load_beats(beat_positions): return data["beat_times"], data["audio_duration"] -def _load_beat_data(beat_positions): +def _parse_beat_payload(beat_positions): + if isinstance(beat_positions, dict): + return beat_positions try: - data = json.loads(beat_positions) + return json.loads(beat_positions) except json.JSONDecodeError as error: raise ValueError(f"Beat positions is not valid JSON: {error.msg}.") from error + + +def _load_beat_data(beat_positions): + data = _parse_beat_payload(beat_positions) if not isinstance(data, dict): raise ValueError("Beat positions must be the JSON object from FL Audio BPM Analyzer.") @@ -67,11 +73,22 @@ def _load_beat_data(beat_positions): raise ValueError("Beat positions contains a beat after audio_duration.") bpm = _number(data.get("bpm", 0.0), "bpm", 0) + base_beat_times = data.get("base_beat_times", beat_times) + if not isinstance(base_beat_times, list): + base_beat_times = beat_times + base_detected_beat_times = data.get( + "base_detected_beat_times", + data.get("detected_beat_times", []), + ) + if not isinstance(base_detected_beat_times, list): + base_detected_beat_times = [] return { "bpm": bpm, "beat_times": beat_times, + "base_beat_times": base_beat_times, "audio_duration": duration, "detected_beat_times": data.get("detected_beat_times", []), + "base_detected_beat_times": base_detected_beat_times, "onset_times": data.get("onset_times", []), "drum_times": data.get("drum_times", {}), "waveform_preview": ( @@ -466,7 +483,10 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode): min=-1000, max=1000, step=1, - tooltip="Shift detected beats earlier or later without moving the audio.", + tooltip=( + "Backing value for the sequencer's live Beat offset control. It shifts the " + "beat grid and detected beats without moving audio, onsets, drums, or prompts." + ), ), io.Combo.Input( "analysis_source", @@ -543,7 +563,11 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode): analysis_source, ) if beat_positions: - beat_data = _load_beat_data(beat_positions) + beat_payload = _parse_beat_payload(beat_positions) + _load_beat_data(beat_payload) + beat_payload = apply_beat_offset(beat_payload, fps, beat_offset_ms) + beat_positions = json.dumps(beat_payload, separators=(",", ":")) + beat_data = _load_beat_data(beat_payload) if internal_analysis is not None: difference = abs(beat_data["audio_duration"] - internal_analysis["audio_duration"]) if difference > max(_EPS, 1.0 / fps): @@ -603,11 +627,21 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode): ui_payload = { "bpm": beat_data["bpm"], "beat_times": beat_times, + "base_beat_times": beat_data["base_beat_times"], "detected_beat_times": ( internal_analysis["detected_beat_times"] if internal_analysis is not None else beat_data["detected_beat_times"] ), + "base_detected_beat_times": ( + internal_analysis.get( + "base_detected_beat_times", + internal_analysis["detected_beat_times"], + ) + if internal_analysis is not None + else beat_data["base_detected_beat_times"] + ), + "beat_offset_ms": int(round(beat_offset_ms)), "onset_times": ( internal_analysis["onset_times"] if internal_analysis is not None diff --git a/nodes/audio/audio_timeline.py b/nodes/audio/audio_timeline.py index f0a08eb..dcdc229 100644 --- a/nodes/audio/audio_timeline.py +++ b/nodes/audio/audio_timeline.py @@ -13,8 +13,8 @@ from .audio_files import audio_file_hash, load_audio_file, resolve_audio_path from .audio_separation import load_cached_stem -ANALYSIS_VERSION = 2 -DETECTOR_VERSION = "fl-audio-timeline-1" +ANALYSIS_VERSION = 3 +DETECTOR_VERSION = "fl-audio-timeline-2" _WAVEFORM_BUCKETS_PER_SECOND = 60 _MAX_WAVEFORM_BUCKETS = 8192 _WAVEFORM_SCALE = 32767 @@ -202,6 +202,40 @@ def _detect_drums(waveform, sample_rate, onset_frames, onset_times): } +def apply_beat_offset(analysis, fps, beat_offset_ms=0): + if not math.isfinite(fps) or fps <= 0: + raise ValueError("FPS must be greater than zero.") + if not math.isfinite(beat_offset_ms): + raise ValueError("Beat offset must be a finite number.") + + duration = float(analysis["audio_duration"]) + offset = beat_offset_ms / 1000.0 + base_beat_times = analysis.get("base_beat_times", analysis.get("beat_times", [])) + base_detected_beat_times = analysis.get( + "base_detected_beat_times", + analysis.get("detected_beat_times", []), + ) + + def shifted(values): + if not values: + return [] + times = np.asarray(values, dtype=np.float64) + return np.unique(np.clip(times + offset, 0, duration)).tolist() + + result = dict(analysis) + result["base_beat_times"] = list(base_beat_times) + result["base_detected_beat_times"] = list(base_detected_beat_times) + result["beat_times"] = shifted(base_beat_times) + result["detected_beat_times"] = shifted(base_detected_beat_times) + result["beat_frames"] = [round(value * fps) for value in result["beat_times"]] + result["detected_beat_frames"] = [ + round(value * fps) for value in result["detected_beat_times"] + ] + result["num_beats"] = len(result["beat_times"]) + result["beat_offset_ms"] = int(round(beat_offset_ms)) + return result + + def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat_offset_ms=0): if bpm_method not in {"beat_intervals", "onset_strength"}: raise ValueError(f"Unknown BPM method: {bpm_method}") @@ -232,10 +266,6 @@ def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat detected_beats = detected_beats[::2] regularized_beats = _regularize_beats(detected_beats, interval, duration) - offset = beat_offset_ms / 1000.0 - if offset: - detected_beats = np.clip(detected_beats + offset, 0, duration) - regularized_beats = np.clip(regularized_beats + offset, 0, duration) detected_beats = np.unique(detected_beats) regularized_beats = np.unique(regularized_beats) if not len(regularized_beats): @@ -253,7 +283,7 @@ def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat detected_times = detected_beats.tolist() onsets = onset_times.tolist() - return { + analysis = { "version": ANALYSIS_VERSION, "detector_version": DETECTOR_VERSION, "bpm": float(bpm), @@ -270,9 +300,10 @@ def analyze_audio(audio, fps, bpm_method="beat_intervals", half_time=False, beat "drum_times": drum_times, "waveform_preview": waveform_preview(waveform, sample_rate), } + return apply_beat_offset(analysis, fps, beat_offset_ms) -def analysis_cache_key(path, fps, trim_start_frame, length_frames, bpm_method, half_time, beat_offset_ms, analysis_source): +def analysis_cache_key(path, fps, trim_start_frame, length_frames, bpm_method, half_time, analysis_source): values = { "audio_sha256": audio_file_hash(path), "fps": float(fps), @@ -280,7 +311,6 @@ def analysis_cache_key(path, fps, trim_start_frame, length_frames, bpm_method, h "length_frames": int(length_frames), "bpm_method": bpm_method, "half_time": bool(half_time), - "beat_offset_ms": int(beat_offset_ms), "analysis_source": analysis_source, "detector_version": DETECTOR_VERSION, } @@ -319,7 +349,6 @@ def analyze_audio_file( length_frames, bpm_method, half_time, - beat_offset_ms, analysis_source, ) cache_path = _cache_path(cache_key) @@ -331,7 +360,6 @@ def analyze_audio_file( fps, bpm_method, half_time, - beat_offset_ms, ) analysis.update(crop) analysis.update({ @@ -342,4 +370,4 @@ def analyze_audio_file( temporary_path = cache_path.with_suffix(".tmp") temporary_path.write_text(json.dumps(analysis, separators=(",", ":")), encoding="utf-8") temporary_path.replace(cache_path) - return analysis, cropped_audio + return apply_beat_offset(analysis, fps, beat_offset_ms), cropped_audio diff --git a/pyproject.toml b/pyproject.toml index 0612af8..d9b2948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "comfyui_fill-nodes" description = "Fill-Nodes is a versatile collection of custom nodes for ComfyUI that extends functionality across multiple domains. Features include advanced image processing (pixelation, slicing, masking), visual effects generation (glitch, halftone, pixel art), comprehensive file handling (PDF creation/extraction, Google Drive integration), AI model interfaces (GPT, DALL-E, Hugging Face), utility nodes for workflow enhancement, and specialized tools for video processing, captioning, and batch operations. The pack provides both practical workflow solutions and creative tools within a unified node collection." -version = "2.16.0" +version = "2.17.0" license = {file = "LICENSE"} dependencies = ["librosa", "sounddevice", "glitch_this", "PyOpenGL", "glfw", "scipy>=1.13.1", "requests", "aiohttp", "moviepy", "matplotlib", "reportlab", "openai", "PyPDF2", "pdf2image", "PyMuPDF", "reportlab", "PyPDF2", "ollama", "kornia", "opencv-python", "gdown", "open_clip_torch", "google-genai"] diff --git a/tests/test_audio_beat_prompt_schedule.py b/tests/test_audio_beat_prompt_schedule.py index 122c708..102c66a 100644 --- a/tests/test_audio_beat_prompt_schedule.py +++ b/tests/test_audio_beat_prompt_schedule.py @@ -352,6 +352,27 @@ class BeatPromptScheduleTests(unittest.TestCase): audio_file="song.wav", ) + def test_scheduler_offset_applies_to_external_beat_positions(self): + output = schedule.FL_Audio_Beat_Prompt_Schedule.execute( + beat_positions=beat_json(), + timeline="[0 - 24]\nCamera pulse.", + default_fade_in=0.0, + default_fade_out=0.0, + curve="linear", + fps=24.0, + sequence_duration=24, + beat_offset_ms=100, + ) + + effective = json.loads(output.result[5]) + payload = output.ui["fl_prompt_sequencer"][0] + self.assertEqual(effective["base_beat_times"], [0.1, 0.6, 1.2, 1.9]) + self.assertEqual(effective["beat_times"], [0.2, 0.7, 1.3, 2.0]) + self.assertEqual(effective["beat_offset_ms"], 100) + self.assertEqual(payload["base_beat_times"], [0.1, 0.6, 1.2, 1.9]) + self.assertEqual(payload["beat_times"], [0.2, 0.7, 1.3, 2.0]) + self.assertEqual(payload["beat_offset_ms"], 100) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_audio_timeline.py b/tests/test_audio_timeline.py index a6db8d2..6e8841a 100644 --- a/tests/test_audio_timeline.py +++ b/tests/test_audio_timeline.py @@ -22,6 +22,37 @@ SPEC.loader.exec_module(timeline) class AudioTimelineTests(unittest.TestCase): + def test_beat_offset_shifts_only_beat_markers(self): + analysis = { + "audio_duration": 1.0, + "beat_times": [0.1, 0.9], + "detected_beat_times": [0.0, 0.95], + "onset_times": [0.2], + "drum_times": {"kick_times": [0.2]}, + } + + shifted = timeline.apply_beat_offset(analysis, fps=24.0, beat_offset_ms=200) + + self.assertEqual(shifted["base_beat_times"], [0.1, 0.9]) + self.assertEqual(shifted["beat_times"], [0.30000000000000004, 1.0]) + self.assertEqual(shifted["detected_beat_times"], [0.2, 1.0]) + self.assertEqual(shifted["beat_frames"], [7, 24]) + self.assertEqual(shifted["onset_times"], [0.2]) + self.assertEqual(shifted["drum_times"], {"kick_times": [0.2]}) + self.assertEqual(shifted["beat_offset_ms"], 200) + + def test_beat_offset_clamps_and_deduplicates_crop_boundaries(self): + analysis = { + "audio_duration": 1.0, + "beat_times": [0.0, 0.1, 0.8], + "detected_beat_times": [], + } + + shifted = timeline.apply_beat_offset(analysis, fps=24.0, beat_offset_ms=-200) + + self.assertEqual(shifted["beat_times"], [0.0, 0.6000000000000001]) + self.assertEqual(shifted["base_beat_times"], [0.0, 0.1, 0.8]) + def test_crop_uses_video_frames_for_sample_boundaries(self): audio = { "waveform": torch.arange(0, 96000, dtype=torch.float32).reshape(1, 1, -1), @@ -97,6 +128,38 @@ class AudioTimelineTests(unittest.TestCase): self.assertEqual(cropped["waveform"].mean(), 1.0) self.assertEqual(analyze.call_args.args[0]["waveform"].mean(), 2.0) + def test_offset_changes_reuse_the_base_analysis_cache(self): + master = {"waveform": torch.ones(1, 1, 48000), "sample_rate": 48000} + analysis = { + "bpm": 120.0, + "beat_times": [0.1, 0.6], + "detected_beat_times": [0.1, 0.6], + "onset_times": [], + "audio_duration": 1.0, + "waveform_preview": {"version": 1, "duration": 1.0, "scale": 32767, "peaks": [0, 1]}, + "drum_times": {}, + } + with tempfile.TemporaryDirectory() as directory: + cache_path = pathlib.Path(directory) / "analysis.json" + with ( + mock.patch.object(timeline, "resolve_audio_path", return_value=pathlib.Path("song.wav")), + mock.patch.object(timeline, "load_audio_file", return_value=(pathlib.Path("song.wav"), master)), + mock.patch.object(timeline, "analysis_cache_key", return_value="key"), + mock.patch.object(timeline, "_cache_path", return_value=cache_path), + mock.patch.object(timeline, "analyze_audio", return_value=analysis) as analyze, + ): + first, _ = timeline.analyze_audio_file("song.wav", fps=24.0) + shifted, _ = timeline.analyze_audio_file( + "song.wav", + fps=24.0, + beat_offset_ms=100, + ) + + self.assertEqual(analyze.call_count, 1) + self.assertEqual(first["beat_times"], [0.1, 0.6]) + self.assertEqual(shifted["beat_times"], [0.2, 0.7]) + self.assertEqual(first["cache_key"], shifted["cache_key"]) + if __name__ == "__main__": unittest.main() diff --git a/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js b/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js index cd722bc..ce18601 100644 --- a/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js +++ b/web/nodes/audio/FL_Audio_Beat_Prompt_Schedule.js @@ -5,7 +5,7 @@ const STYLE_ID = "fl-beat-prompt-sequencer-styles"; const INSTANCES = new Map(); const HEADER_RE = /^\s*\[\s*([0-9]+(?:\.[0-9]+)?)\s*-\s*([0-9]+(?:\.[0-9]+)?)(?:\s*\|\s*(.*?))?\s*\]\s*$/; const EPSILON = 1e-6; -const FORMAT_VERSION = 3; +const FORMAT_VERSION = 4; const MIN_NODE_WIDTH = 680; const MIN_NODE_HEIGHT = 1100; const TIMELINE_LEFT = 42; @@ -88,7 +88,7 @@ const STYLES = ` color: #a1a1aa; font-size: 9px; } - .flbps-control select, .flbps-inspector input, + .flbps-control select, .flbps-control input[type="number"], .flbps-inspector input, .flbps-inspector textarea, .flbps-raw textarea { color: #f4f4f5; background: #252529; @@ -103,7 +103,23 @@ const STYLES = ` padding: 2px 5px; font-size: 9px; } - .flbps-control select:focus, .flbps-inspector input:focus, + .flbps-control input[type="range"] { + width: 110px; + accent-color: #22d3ee; + } + .flbps-control input[type="number"] { + width: 62px; + height: 23px; + padding: 2px 4px; + font-size: 9px; + text-align: right; + } + .flbps-offset-frames { + min-width: 66px; + color: #67e8f9; + font: 9px "Cascadia Mono", Consolas, monospace; + } + .flbps-control select:focus, .flbps-control input[type="number"]:focus, .flbps-inspector input:focus, .flbps-inspector textarea:focus, .flbps-raw textarea:focus { border-color: #22d3ee; } .flbps-canvas-wrap { position: relative; @@ -464,7 +480,7 @@ class BeatPromptSequencer { this.separationTimer = null; const saved = node.properties?.flBeatPromptSequencer || {}; - this.beatData = saved.beatData || null; + this.beatData = saved.formatVersion === FORMAT_VERSION ? saved.beatData || null : null; if (this.beatData) { this.beatData.waveformPreview = normalizeWaveformPreview(this.beatData.waveformPreview); } @@ -478,6 +494,7 @@ class BeatPromptSequencer { injectStyles(); this.build(); this.bindWidgetCallbacks(); + this.applyBeatOffset(); this.loadTimeline(); this.refreshBeatStatus(); if (!(this.viewEnd > this.viewStart)) this.zoomToFit(false); @@ -489,6 +506,10 @@ class BeatPromptSequencer { return Math.max(1, finiteNumber(this.widgets.fps?.value, 24)); } + beatOffsetMs() { + return clamp(Math.round(finiteNumber(this.widgets.beatOffset?.value, 0)), -1000, 1000); + } + configuredFrameCount() { return Math.max(0, Math.round(finiteNumber(this.widgets.sequenceDuration?.value, 0))); } @@ -532,6 +553,13 @@ class BeatPromptSequencer { + + @@ -594,6 +622,9 @@ class BeatPromptSequencer { this.controls = { snap: this.root.querySelector('[data-role="snap"]'), autoAnalyze: this.root.querySelector('[data-role="auto-analyze"]'), + beatOffset: this.root.querySelector('[data-role="beat-offset"]'), + beatOffsetNumber: this.root.querySelector('[data-role="beat-offset-number"]'), + beatOffsetFrames: this.root.querySelector('[data-role="beat-offset-frames"]'), }; this.fields = { start: this.root.querySelector('[data-field="start"]'), @@ -609,6 +640,7 @@ class BeatPromptSequencer { this.controls.snap.value = this.snapMode; this.controls.autoAnalyze.checked = this.autoAnalyze; + this.syncBeatOffsetControls(); this.waveformButton = this.root.querySelector('[data-action="waveform"]'); this.waveformButton.classList.toggle("active", this.waveformVisible); this.controls.snap.addEventListener("change", () => { @@ -631,6 +663,20 @@ class BeatPromptSequencer { this.saveViewState(); if (this.autoAnalyze) this.requestAnalysis(); }); + this.controls.beatOffset.addEventListener("input", () => { + this.setBeatOffset(this.controls.beatOffset.value); + }); + this.controls.beatOffsetNumber.addEventListener("input", () => { + if (this.controls.beatOffsetNumber.value !== "") { + this.setBeatOffset(this.controls.beatOffsetNumber.value); + } + }); + this.controls.beatOffsetNumber.addEventListener("change", () => { + this.setBeatOffset(this.controls.beatOffsetNumber.value); + }); + this.root.querySelector('[data-action="reset-offset"]').addEventListener("click", () => { + this.setBeatOffset(0); + }); this.root.querySelector('[data-action="play"]').addEventListener("click", () => this.togglePlayback()); this.root.querySelector('[data-action="stop"]').addEventListener("click", () => this.stopPlayback()); this.root.querySelector('[data-action="analyze"]').addEventListener("click", () => this.requestAnalysis(true)); @@ -689,6 +735,7 @@ class BeatPromptSequencer { bind(this.widgets.timeUnit, () => this.loadTimeline()); bind(this.widgets.fps, () => { this.syncInspector(); + this.syncBeatOffsetControls(); this.refreshBrowserCrop(); this.zoomToFit(); this.markDirty(); @@ -707,7 +754,7 @@ class BeatPromptSequencer { }); bind(this.widgets.bpmMethod, () => this.scheduleAnalysis()); bind(this.widgets.halfTime, () => this.scheduleAnalysis()); - bind(this.widgets.beatOffset, () => this.scheduleAnalysis()); + bind(this.widgets.beatOffset, (value) => this.setBeatOffset(value, false)); bind(this.widgets.analysisSource, () => this.scheduleAnalysis()); bind(this.widgets.defaultFadeIn, () => this.markDirty()); bind(this.widgets.defaultFadeOut, () => this.markDirty()); @@ -718,6 +765,51 @@ class BeatPromptSequencer { this.node.graph?.change?.(); } + syncBeatOffsetControls() { + if (!this.controls?.beatOffset) return; + const offset = this.beatOffsetMs(); + const sign = offset > 0 ? "+" : ""; + const frames = offset / 1000 * this.fps(); + const frameSign = frames > 0 ? "+" : ""; + this.controls.beatOffset.value = String(offset); + this.controls.beatOffsetNumber.value = String(offset); + this.controls.beatOffsetFrames.textContent = + `${sign}${offset} ms · ${frameSign}${frames.toFixed(2)} fr`; + } + + shiftedMarkerTimes(values) { + const duration = Math.max(0, finiteNumber(this.beatData?.audioDuration)); + const offset = this.beatOffsetMs() / 1000; + const shifted = (values || []) + .map((value) => clamp(finiteNumber(value) + offset, 0, duration)) + .sort((left, right) => left - right); + return shifted.filter((value, index) => index === 0 || Math.abs(value - shifted[index - 1]) > EPSILON); + } + + applyBeatOffset() { + this.syncBeatOffsetControls(); + if (!this.beatData) { + this.scheduleDraw(); + return; + } + this.beatData.beatTimes = this.shiftedMarkerTimes(this.beatData.baseBeatTimes); + this.beatData.detectedBeatTimes = this.shiftedMarkerTimes( + this.beatData.baseDetectedBeatTimes, + ); + this.beatData.beatOffsetMs = this.beatOffsetMs(); + this.refreshBeatStatus(); + this.scheduleDraw(); + } + + setBeatOffset(value, updateWidget = true) { + const offset = clamp(Math.round(finiteNumber(value, 0)), -1000, 1000); + if (this.widgets.beatOffset && (updateWidget || this.widgets.beatOffset.value !== offset)) { + this.widgets.beatOffset.value = offset; + } + this.applyBeatOffset(); + this.markDirty(); + } + saveViewState() { this.node.properties = this.node.properties || {}; const savedBeatData = this.beatData ? { ...this.beatData, waveformPreview: null } : null; @@ -755,6 +847,8 @@ class BeatPromptSequencer { invalidateAnalysis() { if (!this.beatData) return; + this.beatData.baseBeatTimes = []; + this.beatData.baseDetectedBeatTimes = []; this.beatData.beatTimes = []; this.beatData.detectedBeatTimes = []; this.beatData.onsetTimes = []; @@ -881,7 +975,7 @@ class BeatPromptSequencer { length_frames: this.configuredFrameCount(), bpm_method: this.widgets.bpmMethod?.value || "beat_intervals", half_time: Boolean(this.widgets.halfTime?.value), - beat_offset_ms: Math.round(finiteNumber(this.widgets.beatOffset?.value, 0)), + beat_offset_ms: 0, analysis_source: this.widgets.analysisSource?.value || "mix", }), }); @@ -899,10 +993,22 @@ class BeatPromptSequencer { } applyAnalysis(payload, fresh) { + const payloadOffset = finiteNumber(payload.beat_offset_ms, 0) / 1000; + const payloadBeatTimes = (payload.beat_times || []).map((value) => finiteNumber(value)); + const payloadDetectedBeatTimes = (payload.detected_beat_times || []).map( + (value) => finiteNumber(value), + ); this.beatData = { bpm: finiteNumber(payload.bpm), - beatTimes: (payload.beat_times || []).map((value) => finiteNumber(value)), - detectedBeatTimes: (payload.detected_beat_times || []).map((value) => finiteNumber(value)), + baseBeatTimes: (payload.base_beat_times || payloadBeatTimes.map( + (value) => value - payloadOffset, + )).map((value) => finiteNumber(value)), + baseDetectedBeatTimes: ( + payload.base_detected_beat_times || + payloadDetectedBeatTimes.map((value) => value - payloadOffset) + ).map((value) => finiteNumber(value)), + beatTimes: [], + detectedBeatTimes: [], onsetTimes: (payload.onset_times || []).map((value) => finiteNumber(value)), drumTimes: payload.drum_times || {}, audioDuration: finiteNumber(payload.audio_duration), @@ -914,8 +1020,7 @@ class BeatPromptSequencer { cacheKey: payload.cache_key || "", }; this.dataFresh = fresh; - this.refreshBeatStatus(); - this.scheduleDraw(); + this.applyBeatOffset(); } updatePlayButton() { @@ -1216,8 +1321,11 @@ class BeatPromptSequencer { const count = this.beatData.beatTimes?.length || 0; const detected = this.beatData.detectedBeatTimes?.length || 0; const onsets = this.beatData.onsetTimes?.length || 0; + const offset = this.beatOffsetMs(); + const offsetText = offset ? ` · offset ${offset > 0 ? "+" : ""}${offset} ms` : ""; const text = `${finiteNumber(this.beatData.bpm).toFixed(2)} BPM · ${count} grid · ` + - `${detected} detected · ${onsets} onsets · ${finiteNumber(this.beatData.audioDuration).toFixed(2)} sec`; + `${detected} detected · ${onsets} onsets · ${finiteNumber(this.beatData.audioDuration).toFixed(2)} sec` + + offsetText; if (this.dataFresh) { this.statusEl.classList.add("fresh"); this.statusEl.textContent = text; @@ -2279,7 +2387,12 @@ app.registerExtension({ beatOffset: findWidget(node, "beat_offset_ms"), analysisSource: findWidget(node, "analysis_source"), }; - const hiddenWidgets = [widgets.timeline, widgets.timeUnit, widgets.trimStartFrame]; + const hiddenWidgets = [ + widgets.timeline, + widgets.timeUnit, + widgets.trimStartFrame, + widgets.beatOffset, + ]; for (const widget of hiddenWidgets) hideWidget(widget); const container = document.createElement("div"); @@ -2319,6 +2432,7 @@ app.registerExtension({ requestAnimationFrame(() => enforceMinimumNodeSize(this)); const editor = INSTANCES.get(node.id); if (editor) { + editor.applyBeatOffset(); editor.loadTimeline(); editor.refreshBeatStatus(); editor.scheduleDraw();