Add live beat offset control
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flbps-control" title="Shift the beat grid and detected-beat markers without moving the audio, waveform, onsets, drums, or prompt clips.">
|
||||
Beat offset
|
||||
<input data-role="beat-offset" type="range" min="-1000" max="1000" step="1">
|
||||
<input data-role="beat-offset-number" type="number" min="-1000" max="1000" step="1" aria-label="Beat offset in milliseconds">
|
||||
<span class="flbps-offset-frames" data-role="beat-offset-frames"></span>
|
||||
</label>
|
||||
<button class="flbps-button" data-action="reset-offset" title="Reset the beat offset to zero">Zero</button>
|
||||
<button class="flbps-button" data-action="zoom-out" title="Show more frames">Zoom -</button>
|
||||
<button class="flbps-button" data-action="zoom-in" title="Show fewer frames">Zoom +</button>
|
||||
<button class="flbps-button" data-action="fit" title="Show the complete frame range">Fit</button>
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user