Fix audio crop beat alignment
This commit is contained in:
@@ -857,6 +857,9 @@ class FL_Audio_Beat_Prompt_Schedule(io.ComfyNode):
|
||||
if internal_analysis is not None:
|
||||
ui_payload["audio_file"] = internal_analysis.get("audio_file", audio_file)
|
||||
ui_payload["cache_key"] = internal_analysis.get("cache_key", "")
|
||||
source_analysis = internal_analysis.get("source_analysis")
|
||||
if isinstance(source_analysis, dict) and "beat_times" in source_analysis:
|
||||
ui_payload["source_analysis"] = source_analysis
|
||||
waveform = (
|
||||
internal_analysis["waveform_preview"]
|
||||
if internal_analysis is not None
|
||||
|
||||
+174
-57
@@ -14,8 +14,9 @@ from .audio_separation import load_cached_stem
|
||||
from .beat_this_detector import MODEL_FPS, MODEL_SHA256, analyze_beats as analyze_beat_this
|
||||
|
||||
|
||||
ANALYSIS_VERSION = 6
|
||||
ANALYSIS_VERSION = 7
|
||||
DETECTOR_VERSION = f"beat-this-final0-{MODEL_SHA256[:12]}"
|
||||
SOURCE_ANALYSIS_VERSION = 1
|
||||
_WAVEFORM_BUCKETS_PER_SECOND = 60
|
||||
_MAX_WAVEFORM_BUCKETS = 8192
|
||||
_WAVEFORM_SCALE = 32767
|
||||
@@ -114,6 +115,27 @@ def waveform_preview(waveform, sample_rate):
|
||||
}
|
||||
|
||||
|
||||
def crop_waveform_preview(preview, start, duration):
|
||||
if not isinstance(preview, dict) or preview.get("version") != 1 or duration <= 0:
|
||||
return None
|
||||
peaks = preview.get("peaks")
|
||||
source_duration = preview.get("duration")
|
||||
if not isinstance(peaks, list) or len(peaks) < 2 or len(peaks) % 2 or not source_duration:
|
||||
return None
|
||||
bucket_count = len(peaks) // 2
|
||||
start_bucket = min(bucket_count - 1, max(0, math.floor(start / source_duration * bucket_count)))
|
||||
end_bucket = min(
|
||||
bucket_count,
|
||||
max(start_bucket + 1, math.ceil((start + duration) / source_duration * bucket_count)),
|
||||
)
|
||||
return {
|
||||
"version": 1,
|
||||
"duration": float(duration),
|
||||
"scale": preview["scale"],
|
||||
"peaks": peaks[start_bucket * 2:end_bucket * 2],
|
||||
}
|
||||
|
||||
|
||||
def _detect_drums(waveform, sample_rate, onset_frames, onset_times):
|
||||
stft_mag = np.abs(librosa.stft(waveform))
|
||||
frequencies = librosa.fft_frequencies(sr=sample_rate)
|
||||
@@ -275,15 +297,7 @@ def apply_beat_offset(analysis, fps, beat_offset_ms=0, beat_grid_density="every_
|
||||
return result
|
||||
|
||||
|
||||
def analyze_audio(
|
||||
audio,
|
||||
fps,
|
||||
half_time=False,
|
||||
beat_offset_ms=0,
|
||||
beat_grid_density="every_beat",
|
||||
detect_beats=True,
|
||||
beat_audio=None,
|
||||
):
|
||||
def analyze_audio(audio, detect_beats=True, beat_audio=None):
|
||||
waveform = mono_numpy(audio)
|
||||
sample_rate = int(audio["sample_rate"])
|
||||
duration = len(waveform) / sample_rate
|
||||
@@ -302,7 +316,6 @@ def analyze_audio(
|
||||
"version": ANALYSIS_VERSION,
|
||||
"detector_version": DETECTOR_VERSION,
|
||||
"onset_times": onsets,
|
||||
"onset_frames": [round(value * fps) for value in onsets],
|
||||
"sample_rate": sample_rate,
|
||||
"audio_duration": float(duration),
|
||||
"drum_times": drum_times,
|
||||
@@ -320,20 +333,8 @@ def analyze_audio(
|
||||
downbeat_confidences = np.asarray(
|
||||
detected["downbeat_confidences"], dtype=np.float64
|
||||
)
|
||||
if half_time:
|
||||
retained = np.arange(0, len(beat_times), 2)
|
||||
retained_indices = set(retained.tolist())
|
||||
downbeat_retained = []
|
||||
for index, downbeat in enumerate(downbeat_times):
|
||||
nearest = int(np.argmin(np.abs(beat_times - downbeat)))
|
||||
if nearest in retained_indices:
|
||||
downbeat_retained.append(index)
|
||||
beat_times = beat_times[retained]
|
||||
beat_confidences = beat_confidences[retained]
|
||||
downbeat_times = downbeat_times[downbeat_retained]
|
||||
downbeat_confidences = downbeat_confidences[downbeat_retained]
|
||||
if len(beat_times) < 2:
|
||||
raise ValueError("Beat This must detect at least two beats in the selected audio range.")
|
||||
raise ValueError("Beat This must detect at least two beats in the source audio.")
|
||||
|
||||
interval = float(np.median(np.diff(beat_times)))
|
||||
analysis.update({
|
||||
@@ -348,32 +349,141 @@ def analyze_audio(
|
||||
"detected_beat_confidences": beat_confidences.tolist(),
|
||||
"base_detected_downbeat_confidences": downbeat_confidences.tolist(),
|
||||
"detected_downbeat_confidences": downbeat_confidences.tolist(),
|
||||
"beat_frames": [round(value * fps) for value in beat_times],
|
||||
"downbeat_frames": [round(value * fps) for value in downbeat_times],
|
||||
"detected_beat_frames": [round(value * fps) for value in beat_times],
|
||||
"num_beats": len(beat_times),
|
||||
"num_downbeats": len(downbeat_times),
|
||||
"detector": detected["detector"],
|
||||
})
|
||||
return apply_beat_offset(analysis, fps, beat_offset_ms, beat_grid_density)
|
||||
return analysis
|
||||
|
||||
|
||||
def apply_half_time(analysis, half_time):
|
||||
result = dict(analysis)
|
||||
if not half_time or "beat_times" not in result:
|
||||
return result
|
||||
|
||||
beat_times = list(result["beat_times"])
|
||||
beat_confidences = list(result.get("detected_beat_confidences", []))
|
||||
retained = list(range(0, len(beat_times), 2))
|
||||
if len(retained) < 2:
|
||||
raise ValueError("Beat This must detect at least two beats after half-time filtering.")
|
||||
retained_indices = set(retained)
|
||||
downbeat_times = list(result.get("downbeat_times", []))
|
||||
downbeat_confidences = list(result.get("detected_downbeat_confidences", []))
|
||||
retained_downbeats = []
|
||||
retained_downbeat_confidences = []
|
||||
for index, downbeat in enumerate(downbeat_times):
|
||||
nearest = min(range(len(beat_times)), key=lambda position: abs(beat_times[position] - downbeat))
|
||||
if nearest in retained_indices:
|
||||
retained_downbeats.append(downbeat)
|
||||
if index < len(downbeat_confidences):
|
||||
retained_downbeat_confidences.append(downbeat_confidences[index])
|
||||
|
||||
retained_beats = [beat_times[index] for index in retained]
|
||||
retained_beat_confidences = [
|
||||
beat_confidences[index] for index in retained if index < len(beat_confidences)
|
||||
]
|
||||
interval = float(np.median(np.diff(retained_beats)))
|
||||
result.update({
|
||||
"bpm": 60.0 / interval,
|
||||
"base_grid_interval_seconds": interval,
|
||||
"beat_times": retained_beats,
|
||||
"downbeat_times": retained_downbeats,
|
||||
"detected_beat_times": retained_beats,
|
||||
"detected_downbeat_times": retained_downbeats,
|
||||
"base_detected_beat_confidences": retained_beat_confidences,
|
||||
"detected_beat_confidences": retained_beat_confidences,
|
||||
"base_detected_downbeat_confidences": retained_downbeat_confidences,
|
||||
"detected_downbeat_confidences": retained_downbeat_confidences,
|
||||
"num_beats": len(retained_beats),
|
||||
"num_downbeats": len(retained_downbeats),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _crop_times(values, start, end):
|
||||
return [float(value - start) for value in values if start <= value < end]
|
||||
|
||||
|
||||
def _crop_times_with_values(times, values, start, end):
|
||||
cropped_times = []
|
||||
cropped_values = []
|
||||
for index, value in enumerate(times):
|
||||
if start <= value < end:
|
||||
cropped_times.append(float(value - start))
|
||||
if index < len(values):
|
||||
cropped_values.append(values[index])
|
||||
return cropped_times, cropped_values
|
||||
|
||||
|
||||
def project_analysis(analysis, crop, fps):
|
||||
start = float(crop["source_start"])
|
||||
duration = float(crop["audio_duration"])
|
||||
end = start + duration
|
||||
result = dict(analysis)
|
||||
|
||||
for time_key, confidence_key in (
|
||||
("base_detected_beat_times", "base_detected_beat_confidences"),
|
||||
("detected_beat_times", "detected_beat_confidences"),
|
||||
("base_detected_downbeat_times", "base_detected_downbeat_confidences"),
|
||||
("detected_downbeat_times", "detected_downbeat_confidences"),
|
||||
):
|
||||
times, confidences = _crop_times_with_values(
|
||||
analysis.get(time_key, []),
|
||||
analysis.get(confidence_key, []),
|
||||
start,
|
||||
end,
|
||||
)
|
||||
result[time_key] = times
|
||||
result[confidence_key] = confidences
|
||||
|
||||
for key in (
|
||||
"base_beat_times",
|
||||
"beat_times",
|
||||
"base_downbeat_times",
|
||||
"downbeat_times",
|
||||
"onset_times",
|
||||
):
|
||||
if key in analysis:
|
||||
result[key] = _crop_times(analysis[key], start, end)
|
||||
|
||||
drums = dict(analysis.get("drum_times", {}))
|
||||
for key in ("kick_times", "snare_times", "hihat_times"):
|
||||
drums[key] = _crop_times(drums.get(key, []), start, end)
|
||||
drums.update({
|
||||
"duration": duration,
|
||||
"total_kicks": len(drums["kick_times"]),
|
||||
"total_snares": len(drums["snare_times"]),
|
||||
"total_hihats": len(drums["hihat_times"]),
|
||||
})
|
||||
|
||||
result.update(crop)
|
||||
result.update({
|
||||
"beat_frames": [round(value * fps) for value in result.get("beat_times", [])],
|
||||
"downbeat_frames": [round(value * fps) for value in result.get("downbeat_times", [])],
|
||||
"detected_beat_frames": [
|
||||
round(value * fps) for value in result.get("detected_beat_times", [])
|
||||
],
|
||||
"onset_frames": [round(value * fps) for value in result.get("onset_times", [])],
|
||||
"drum_times": drums,
|
||||
"num_beats": len(result.get("beat_times", [])),
|
||||
"num_downbeats": len(result.get("downbeat_times", [])),
|
||||
"waveform_preview": crop_waveform_preview(
|
||||
analysis.get("waveform_preview"),
|
||||
start,
|
||||
duration,
|
||||
),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def analysis_cache_key(
|
||||
path,
|
||||
fps,
|
||||
trim_start_frame,
|
||||
length_frames,
|
||||
half_time,
|
||||
analysis_source,
|
||||
detect_beats=True,
|
||||
):
|
||||
values = {
|
||||
"analysis_version": ANALYSIS_VERSION,
|
||||
"audio_sha256": audio_file_hash(path),
|
||||
"fps": float(fps),
|
||||
"trim_start_frame": int(trim_start_frame),
|
||||
"length_frames": int(length_frames),
|
||||
"half_time": bool(half_time),
|
||||
"analysis_source": analysis_source,
|
||||
"detect_beats": bool(detect_beats),
|
||||
"detector_version": DETECTOR_VERSION,
|
||||
@@ -426,50 +536,57 @@ def analyze_audio_file(
|
||||
path = resolve_audio_path(filename)
|
||||
_, master_audio = load_audio_file(filename)
|
||||
cropped_audio, crop = crop_audio(master_audio, fps, trim_start_frame, length_frames)
|
||||
if analysis_source == "mix":
|
||||
analysis_audio = cropped_audio
|
||||
else:
|
||||
stem_audio = load_cached_stem(filename, analysis_source)
|
||||
analysis_audio, _ = crop_audio(stem_audio, fps, trim_start_frame, length_frames)
|
||||
cache_key = analysis_cache_key(
|
||||
path,
|
||||
fps,
|
||||
trim_start_frame,
|
||||
length_frames,
|
||||
half_time,
|
||||
analysis_source,
|
||||
detect_beats,
|
||||
)
|
||||
cache_path = _cache_path(cache_key)
|
||||
cache_hit = cache_path.is_file()
|
||||
if cache_hit:
|
||||
analysis = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
source_analysis = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
analysis = analyze_audio(
|
||||
analysis_audio,
|
||||
fps,
|
||||
half_time,
|
||||
detect_beats=detect_beats,
|
||||
beat_audio=cropped_audio if analysis_source != "mix" and detect_beats else None,
|
||||
analysis_audio = (
|
||||
master_audio
|
||||
if analysis_source == "mix"
|
||||
else load_cached_stem(filename, analysis_source)
|
||||
)
|
||||
analysis.update(crop)
|
||||
analysis.update({
|
||||
source_analysis = analyze_audio(
|
||||
analysis_audio,
|
||||
detect_beats=detect_beats,
|
||||
beat_audio=master_audio if analysis_source != "mix" and detect_beats else None,
|
||||
)
|
||||
source_analysis.update({
|
||||
"audio_file": filename,
|
||||
"analysis_source": analysis_source,
|
||||
"beat_analysis_source": "mix" if detect_beats else None,
|
||||
"cache_key": cache_key,
|
||||
"audio_duration": crop["source_duration"],
|
||||
"source_duration": crop["source_duration"],
|
||||
"source_start": 0.0,
|
||||
})
|
||||
temporary_path = cache_path.with_suffix(".tmp")
|
||||
temporary_path.write_text(json.dumps(analysis, separators=(",", ":")), encoding="utf-8")
|
||||
temporary_path.write_text(
|
||||
json.dumps(source_analysis, separators=(",", ":")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary_path.replace(cache_path)
|
||||
if detect_beats:
|
||||
analysis = apply_beat_offset(
|
||||
analysis,
|
||||
apply_half_time(source_analysis, half_time),
|
||||
fps,
|
||||
beat_offset_ms,
|
||||
beat_grid_density,
|
||||
)
|
||||
else:
|
||||
analysis = dict(analysis)
|
||||
analysis = dict(source_analysis)
|
||||
analysis = project_analysis(analysis, crop, fps)
|
||||
analysis["source_analysis"] = {
|
||||
**source_analysis,
|
||||
"type": "fl_audio_source_analysis",
|
||||
"version": SOURCE_ANALYSIS_VERSION,
|
||||
"analysis_version": ANALYSIS_VERSION,
|
||||
"analysis_cache_hit": cache_hit,
|
||||
}
|
||||
analysis["analysis_cache_hit"] = cache_hit
|
||||
return analysis, cropped_audio
|
||||
|
||||
@@ -498,6 +498,11 @@ class BeatPromptScheduleTests(unittest.TestCase):
|
||||
"hihat_times": [],
|
||||
"duration": 1.0,
|
||||
},
|
||||
"source_analysis": {
|
||||
"type": "fl_audio_source_analysis",
|
||||
"version": 1,
|
||||
"beat_times": [0.5, 1.0, 1.5],
|
||||
},
|
||||
}
|
||||
with mock.patch.object(schedule, "analyze_audio_file", return_value=(analysis, audio)):
|
||||
output = schedule.FL_Audio_Beat_Prompt_Schedule.execute(
|
||||
@@ -524,6 +529,8 @@ class BeatPromptScheduleTests(unittest.TestCase):
|
||||
self.assertEqual(payload["beat_analysis_source"], "mix")
|
||||
self.assertEqual(payload["drum_times"]["kick_times"], [0.05])
|
||||
self.assertEqual(payload["source_start"], 0.5)
|
||||
self.assertIs(payload["source_analysis"], analysis["source_analysis"])
|
||||
self.assertNotIn("source_analysis", output.result[0])
|
||||
|
||||
def test_cached_analysis_restores_a_missing_audio_widget_value(self):
|
||||
audio = {"waveform": torch.zeros(1, 1, 24000), "sample_rate": 24000}
|
||||
|
||||
+157
-10
@@ -42,7 +42,7 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
mock.patch.object(timeline, "_detect_drums", return_value={}),
|
||||
mock.patch.object(timeline, "waveform_preview", return_value={}),
|
||||
):
|
||||
analysis = timeline.analyze_audio(audio, fps=24.0)
|
||||
analysis = timeline.analyze_audio(audio)
|
||||
|
||||
self.assertAlmostEqual(analysis["bpm"], 120.0)
|
||||
self.assertEqual(analysis["bpm_source"], "beat_this_intervals_median")
|
||||
@@ -64,7 +64,7 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
mock.patch.object(timeline, "_detect_drums", return_value={}),
|
||||
mock.patch.object(timeline, "waveform_preview", return_value={}),
|
||||
):
|
||||
analysis = timeline.analyze_audio(audio, fps=24.0, detect_beats=False)
|
||||
analysis = timeline.analyze_audio(audio, detect_beats=False)
|
||||
|
||||
detector.assert_not_called()
|
||||
self.assertNotIn("beat_times", analysis)
|
||||
@@ -88,7 +88,7 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
mock.patch.object(timeline, "_detect_drums", return_value={}),
|
||||
mock.patch.object(timeline, "waveform_preview", return_value={}),
|
||||
):
|
||||
timeline.analyze_audio(stem, fps=24.0, beat_audio=mix)
|
||||
timeline.analyze_audio(stem, beat_audio=mix)
|
||||
|
||||
self.assertEqual(beat_this.call_args.args[1], 200)
|
||||
timeline.np.testing.assert_array_equal(beat_this.call_args.args[0], timeline.np.ones(400))
|
||||
@@ -284,7 +284,7 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
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, "load_cached_stem", return_value=stem),
|
||||
mock.patch.object(timeline, "load_cached_stem", return_value=stem) as load_stem,
|
||||
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,
|
||||
@@ -295,10 +295,20 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
length_frames=24,
|
||||
analysis_source="drums",
|
||||
)
|
||||
_, second_crop = timeline.analyze_audio_file(
|
||||
"song.wav",
|
||||
fps=24.0,
|
||||
trim_start_frame=12,
|
||||
length_frames=12,
|
||||
analysis_source="drums",
|
||||
)
|
||||
|
||||
self.assertEqual(cropped["waveform"].mean(), 1.0)
|
||||
self.assertEqual(second_crop["waveform"].mean(), 1.0)
|
||||
self.assertEqual(analyze.call_args.args[0]["waveform"].mean(), 2.0)
|
||||
self.assertEqual(analyze.call_args.kwargs["beat_audio"]["waveform"].mean(), 1.0)
|
||||
self.assertEqual(analyze.call_count, 1)
|
||||
self.assertEqual(load_stem.call_count, 1)
|
||||
|
||||
def test_offset_changes_reuse_the_base_analysis_cache(self):
|
||||
master = {"waveform": torch.ones(1, 1, 48000), "sample_rate": 48000}
|
||||
@@ -366,14 +376,151 @@ class AudioTimelineTests(unittest.TestCase):
|
||||
self.assertEqual(every_two["beat_times"], [0.0, 0.5])
|
||||
self.assertEqual(every_beat["cache_key"], every_two["cache_key"])
|
||||
|
||||
def test_different_crops_reuse_full_source_analysis_and_rebase_events(self):
|
||||
master = {"waveform": torch.ones(1, 1, 96000), "sample_rate": 24000}
|
||||
source_analysis = {
|
||||
"bpm": 60.0,
|
||||
"base_grid_interval_seconds": 1.0,
|
||||
"beat_times": [0.25, 1.25, 2.25, 3.25],
|
||||
"downbeat_times": [0.25, 2.25],
|
||||
"detected_beat_times": [0.25, 1.25, 2.25, 3.25],
|
||||
"detected_downbeat_times": [0.25, 2.25],
|
||||
"detected_beat_confidences": [0.9, 0.8, 0.7, 0.6],
|
||||
"detected_downbeat_confidences": [0.95, 0.75],
|
||||
"onset_times": [0.5, 1.5, 2.5, 3.5],
|
||||
"audio_duration": 4.0,
|
||||
"waveform_preview": {
|
||||
"version": 1,
|
||||
"duration": 4.0,
|
||||
"scale": 32767,
|
||||
"peaks": list(range(16)),
|
||||
},
|
||||
"drum_times": {
|
||||
"kick_times": [1.25, 3.25],
|
||||
"snare_times": [2.25],
|
||||
"hihat_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=source_analysis) as analyze,
|
||||
):
|
||||
first, first_audio = timeline.analyze_audio_file(
|
||||
"song.wav", fps=24.0, trim_start_frame=24, length_frames=48
|
||||
)
|
||||
second, second_audio = timeline.analyze_audio_file(
|
||||
"song.wav", fps=24.0, trim_start_frame=48, length_frames=48
|
||||
)
|
||||
|
||||
self.assertEqual(analyze.call_count, 1)
|
||||
self.assertEqual(analyze.call_args.args[0]["waveform"].shape[-1], 96000)
|
||||
self.assertEqual(first_audio["waveform"].shape[-1], 48000)
|
||||
self.assertEqual(second_audio["waveform"].shape[-1], 48000)
|
||||
self.assertEqual(first["beat_times"], [0.25, 1.25])
|
||||
self.assertEqual(second["beat_times"], [0.25, 1.25])
|
||||
self.assertEqual(first["detected_beat_confidences"], [0.8, 0.7])
|
||||
self.assertEqual(first["onset_times"], [0.5, 1.5])
|
||||
self.assertEqual(first["drum_times"]["kick_times"], [0.25])
|
||||
self.assertEqual(first["source_start"], 1.0)
|
||||
self.assertEqual(second["source_start"], 2.0)
|
||||
self.assertEqual(first["source_analysis"]["beat_times"], [0.25, 1.25, 2.25, 3.25])
|
||||
self.assertFalse(first["analysis_cache_hit"])
|
||||
self.assertTrue(second["analysis_cache_hit"])
|
||||
|
||||
def test_fps_half_time_and_density_project_from_one_source_cache(self):
|
||||
master = {"waveform": torch.ones(1, 1, 96000), "sample_rate": 24000}
|
||||
source_analysis = {
|
||||
"bpm": 120.0,
|
||||
"base_grid_interval_seconds": 0.5,
|
||||
"beat_times": [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5],
|
||||
"downbeat_times": [0.0, 2.0],
|
||||
"detected_beat_times": [0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5],
|
||||
"detected_downbeat_times": [0.0, 2.0],
|
||||
"detected_beat_confidences": [0.9] * 8,
|
||||
"detected_downbeat_confidences": [0.95, 0.85],
|
||||
"onset_times": [],
|
||||
"audio_duration": 4.0,
|
||||
"waveform_preview": None,
|
||||
"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=source_analysis) as analyze,
|
||||
):
|
||||
regular, _ = timeline.analyze_audio_file(
|
||||
"song.wav", fps=24.0, trim_start_frame=12, length_frames=48
|
||||
)
|
||||
half_time, _ = timeline.analyze_audio_file(
|
||||
"song.wav",
|
||||
fps=48.0,
|
||||
trim_start_frame=24,
|
||||
length_frames=96,
|
||||
half_time=True,
|
||||
)
|
||||
every_two, _ = timeline.analyze_audio_file(
|
||||
"song.wav",
|
||||
fps=24.0,
|
||||
trim_start_frame=12,
|
||||
length_frames=48,
|
||||
beat_grid_density="every_2_beats",
|
||||
)
|
||||
|
||||
self.assertEqual(analyze.call_count, 1)
|
||||
self.assertEqual(regular["beat_times"], [0.0, 0.5, 1.0, 1.5])
|
||||
self.assertEqual(half_time["beat_times"], [0.5, 1.5])
|
||||
self.assertEqual(every_two["beat_times"], [0.5, 1.5])
|
||||
self.assertEqual(regular["cache_key"], half_time["cache_key"])
|
||||
self.assertEqual(regular["cache_key"], every_two["cache_key"])
|
||||
|
||||
def test_projection_uses_half_open_boundaries_and_keeps_confidence_pairs(self):
|
||||
analysis = {
|
||||
"beat_times": [1.0, 1.5, 2.0],
|
||||
"downbeat_times": [1.0, 2.0],
|
||||
"base_beat_times": [1.0, 1.5, 2.0],
|
||||
"base_downbeat_times": [1.0, 2.0],
|
||||
"detected_beat_times": [1.0, 1.5, 2.0],
|
||||
"base_detected_beat_times": [1.0, 1.5, 2.0],
|
||||
"detected_beat_confidences": [0.9, 0.8, 0.7],
|
||||
"base_detected_beat_confidences": [0.9, 0.8, 0.7],
|
||||
"detected_downbeat_times": [1.0, 2.0],
|
||||
"base_detected_downbeat_times": [1.0, 2.0],
|
||||
"detected_downbeat_confidences": [0.95, 0.75],
|
||||
"base_detected_downbeat_confidences": [0.95, 0.75],
|
||||
"onset_times": [1.0, 2.0],
|
||||
"drum_times": {"kick_times": [1.0, 2.0]},
|
||||
"waveform_preview": None,
|
||||
}
|
||||
crop = {
|
||||
"source_duration": 3.0,
|
||||
"source_start": 1.0,
|
||||
"trim_start_frame": 24,
|
||||
"length_frames": 24,
|
||||
"audio_duration": 1.0,
|
||||
"sample_rate": 48000,
|
||||
}
|
||||
|
||||
projected = timeline.project_analysis(analysis, crop, fps=24.0)
|
||||
|
||||
self.assertEqual(projected["beat_times"], [0.0, 0.5])
|
||||
self.assertEqual(projected["detected_beat_confidences"], [0.9, 0.8])
|
||||
self.assertEqual(projected["downbeat_times"], [0.0])
|
||||
self.assertEqual(projected["onset_times"], [0.0])
|
||||
self.assertEqual(projected["drum_times"]["kick_times"], [0.0])
|
||||
|
||||
def test_beat_detection_mode_has_a_separate_versioned_cache_key(self):
|
||||
with mock.patch.object(timeline, "audio_file_hash", return_value="audio"):
|
||||
beats = timeline.analysis_cache_key(
|
||||
pathlib.Path("song.wav"), 24.0, 0, 0, False, "mix", True
|
||||
)
|
||||
auxiliary = timeline.analysis_cache_key(
|
||||
pathlib.Path("song.wav"), 24.0, 0, 0, False, "mix", False
|
||||
)
|
||||
beats = timeline.analysis_cache_key(pathlib.Path("song.wav"), "mix", True)
|
||||
auxiliary = timeline.analysis_cache_key(pathlib.Path("song.wav"), "mix", False)
|
||||
|
||||
self.assertNotEqual(beats, auxiliary)
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const moduleSource = await readFile(
|
||||
new URL("../web/nodes/audio/audio_timeline_coordinates.js", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const {
|
||||
cropTimes,
|
||||
cropTimesWithValues,
|
||||
sourceTimes,
|
||||
sourceTimeAtLocalFrame,
|
||||
sourceTimeToLocalFrame,
|
||||
waveformBinRange,
|
||||
} = await import(`data:text/javascript;base64,${Buffer.from(moduleSource).toString("base64")}`);
|
||||
|
||||
test("crop projection uses half-open bounds and preserves paired values", () => {
|
||||
assert.deepEqual(cropTimes([1, 1.5, 2], 1, 2), [0, 0.5]);
|
||||
assert.deepEqual(
|
||||
cropTimesWithValues([1, 1.5, 2], [0.9, 0.8, 0.7], 1, 2),
|
||||
[[0, 0.5], [0.9, 0.8]],
|
||||
);
|
||||
});
|
||||
|
||||
test("waveform and analyzed beats share the same crop origin", () => {
|
||||
const fps = 24;
|
||||
const sourceTime = 10.25;
|
||||
const firstCrop = 2;
|
||||
const secondCrop = 3.5;
|
||||
const firstFrame = sourceTimeToLocalFrame(sourceTime, firstCrop, fps);
|
||||
const secondFrame = sourceTimeToLocalFrame(sourceTime, secondCrop, fps);
|
||||
|
||||
assert.equal(firstFrame - secondFrame, (secondCrop - firstCrop) * fps);
|
||||
assert.equal(sourceTimeAtLocalFrame(firstFrame, firstCrop, fps), sourceTime);
|
||||
assert.equal(sourceTimeAtLocalFrame(secondFrame, secondCrop, fps), sourceTime);
|
||||
|
||||
const firstBins = waveformBinRange(firstFrame, firstFrame + 1, firstCrop, fps, 0, 20, 2000);
|
||||
const secondBins = waveformBinRange(secondFrame, secondFrame + 1, secondCrop, fps, 0, 20, 2000);
|
||||
assert.deepEqual(firstBins, secondBins);
|
||||
});
|
||||
|
||||
test("beat offset moves only the working grid", () => {
|
||||
const fps = 24;
|
||||
const cropStart = 2;
|
||||
const detectedSourceTime = 10.25;
|
||||
const detectedFrame = sourceTimeToLocalFrame(detectedSourceTime, cropStart, fps);
|
||||
const waveformSourceTime = sourceTimeAtLocalFrame(detectedFrame, cropStart, fps);
|
||||
const shiftedGridFrame = sourceTimeToLocalFrame(detectedSourceTime + 0.125, cropStart, fps);
|
||||
|
||||
assert.equal(waveformSourceTime, detectedSourceTime);
|
||||
assert.equal(shiftedGridFrame - detectedFrame, 3);
|
||||
});
|
||||
|
||||
test("legacy crop-local markers are promoted without destructive reprojection", () => {
|
||||
const source = sourceTimes([0.25, 1.25, 2.25], 4);
|
||||
|
||||
assert.deepEqual(cropTimes(source, 4, 6), [0.25, 1.25]);
|
||||
assert.deepEqual(cropTimes(source, 5, 7), [0.25, 1.25]);
|
||||
assert.deepEqual(cropTimes(source, 4, 6), [0.25, 1.25]);
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
import { app } from "../../../../scripts/app.js";
|
||||
import { api } from "../../../../scripts/api.js";
|
||||
import {
|
||||
cropTimes,
|
||||
cropTimesWithValues,
|
||||
sourceTimes,
|
||||
sourceTimeToLocalFrame,
|
||||
waveformBinRange,
|
||||
} from "./audio_timeline_coordinates.js";
|
||||
|
||||
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 HEADER_START_RE = /^\s*\[\s*[0-9]+(?:\.[0-9]+)?\s*-/;
|
||||
const EPSILON = 1e-6;
|
||||
const FORMAT_VERSION = 13;
|
||||
const COMPATIBLE_FORMAT_VERSIONS = new Set([6, 7, 8, 9, 10, 11, 12, FORMAT_VERSION]);
|
||||
const FORMAT_VERSION = 15;
|
||||
const COMPATIBLE_FORMAT_VERSIONS = new Set([6, 7, 8, 9, 10, 11, 12, 13, 14, FORMAT_VERSION]);
|
||||
const SOURCE_ANALYSIS_VERSION = 1;
|
||||
const LEGACY_BPM_METHODS = new Set(["beat_intervals", "onset_strength"]);
|
||||
const COMPACT_NODE_WIDTH = 380;
|
||||
const MEDIA_FILE_RE = /\.(?:aac|aiff?|flac|m4a|mka|mkv|mov|mp3|mp4|oga|ogg|opus|wav|webm|wma)$/i;
|
||||
@@ -597,7 +605,7 @@ function setWidgetValue(widget, value) {
|
||||
}
|
||||
|
||||
function restoreCachedAudioWidgets(widgets, saved) {
|
||||
const beatData = saved?.beatData;
|
||||
const beatData = saved?.sourceAnalysis || saved?.beatData;
|
||||
if (!beatData) return;
|
||||
const audioFile = String(beatData.audioFile || "");
|
||||
const cacheKey = String(beatData.cacheKey || "");
|
||||
@@ -894,6 +902,165 @@ function cropWaveformPreview(preview, startSeconds, duration) {
|
||||
};
|
||||
}
|
||||
|
||||
function analysisArray(value, snake, camel = snake) {
|
||||
const values = value?.[snake] ?? value?.[camel];
|
||||
return Array.isArray(values) ? values.map((entry) => finiteNumber(entry)) : [];
|
||||
}
|
||||
|
||||
function sourceAnalysisValue(value) {
|
||||
if (!value || value.type !== "fl_audio_source_analysis" ||
|
||||
finiteNumber(value.version) !== SOURCE_ANALYSIS_VERSION) {
|
||||
return null;
|
||||
}
|
||||
const duration = finiteNumber(
|
||||
value.source_duration ?? value.sourceDuration ?? value.audio_duration ?? value.audioDuration,
|
||||
);
|
||||
if (!(duration > 0)) return null;
|
||||
return {
|
||||
type: "fl_audio_source_analysis",
|
||||
version: SOURCE_ANALYSIS_VERSION,
|
||||
analysisVersion: finiteNumber(value.analysis_version ?? value.analysisVersion),
|
||||
bpm: finiteNumber(value.bpm),
|
||||
baseGridIntervalSeconds: finiteNumber(
|
||||
value.base_grid_interval_seconds ?? value.baseGridIntervalSeconds,
|
||||
),
|
||||
beatTimes: analysisArray(value, "beat_times", "beatTimes"),
|
||||
downbeatTimes: analysisArray(value, "downbeat_times", "downbeatTimes"),
|
||||
detectedBeatTimes: analysisArray(value, "detected_beat_times", "detectedBeatTimes"),
|
||||
detectedDownbeatTimes: analysisArray(value, "detected_downbeat_times", "detectedDownbeatTimes"),
|
||||
detectedBeatConfidences: analysisArray(value, "detected_beat_confidences", "detectedBeatConfidences"),
|
||||
detectedDownbeatConfidences: analysisArray(
|
||||
value,
|
||||
"detected_downbeat_confidences",
|
||||
"detectedDownbeatConfidences",
|
||||
),
|
||||
onsetTimes: analysisArray(value, "onset_times", "onsetTimes"),
|
||||
drumTimes: value.drum_times || value.drumTimes || {},
|
||||
duration,
|
||||
supportsHalfTime: value.supports_half_time == null && value.supportsHalfTime == null
|
||||
? true
|
||||
: Boolean(value.supports_half_time ?? value.supportsHalfTime),
|
||||
waveformPreview: normalizeWaveformPreview(value.waveform_preview || value.waveformPreview),
|
||||
waveformPreviewStart: finiteNumber(
|
||||
value.waveform_preview_start ?? value.waveformPreviewStart,
|
||||
),
|
||||
cacheKey: String(value.cache_key || value.cacheKey || ""),
|
||||
audioFile: String(value.audio_file || value.audioFile || ""),
|
||||
detector: value.detector || null,
|
||||
detectorVersion: String(value.detector_version || value.detectorVersion || ""),
|
||||
bpmSource: String(value.bpm_source || value.bpmSource || ""),
|
||||
analysisSource: String(value.analysis_source || value.analysisSource || "mix"),
|
||||
beatAnalysisSource: String(value.beat_analysis_source || value.beatAnalysisSource || "mix"),
|
||||
analysisCacheHit: Boolean(value.analysis_cache_hit ?? value.analysisCacheHit),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceAnalysisFromCropPayload(value) {
|
||||
if (!value) return null;
|
||||
const sourceStart = Math.max(0, finiteNumber(value.source_start ?? value.sourceStart));
|
||||
const cropDuration = Math.max(0, finiteNumber(value.audio_duration ?? value.audioDuration));
|
||||
const sourceDuration = Math.max(
|
||||
sourceStart + cropDuration,
|
||||
finiteNumber(value.source_duration ?? value.sourceDuration),
|
||||
);
|
||||
if (!(cropDuration > 0) || !(sourceDuration > 0)) return null;
|
||||
|
||||
const offset = finiteNumber(value.beat_offset_ms ?? value.beatOffsetMs) / 1000;
|
||||
const payloadBeats = analysisArray(value, "beat_times", "beatTimes");
|
||||
const payloadDownbeats = analysisArray(value, "downbeat_times", "downbeatTimes");
|
||||
const baseBeats = analysisArray(value, "base_beat_times", "baseBeatTimes");
|
||||
const baseDownbeats = analysisArray(value, "base_downbeat_times", "baseDownbeatTimes");
|
||||
const baseDetectedBeats = analysisArray(
|
||||
value,
|
||||
"base_detected_beat_times",
|
||||
"baseDetectedBeatTimes",
|
||||
);
|
||||
const baseDetectedDownbeats = analysisArray(
|
||||
value,
|
||||
"base_detected_downbeat_times",
|
||||
"baseDetectedDownbeatTimes",
|
||||
);
|
||||
const baseBeatConfidences = analysisArray(
|
||||
value,
|
||||
"base_detected_beat_confidences",
|
||||
"baseDetectedBeatConfidences",
|
||||
);
|
||||
const baseDownbeatConfidences = analysisArray(
|
||||
value,
|
||||
"base_detected_downbeat_confidences",
|
||||
"baseDetectedDownbeatConfidences",
|
||||
);
|
||||
const drums = { ...(value.drum_times || value.drumTimes || {}) };
|
||||
for (const [snake, camel] of [
|
||||
["kick_times", "kickTimes"],
|
||||
["snare_times", "snareTimes"],
|
||||
["hihat_times", "hihatTimes"],
|
||||
]) {
|
||||
drums[snake] = sourceTimes(analysisArray(drums, snake, camel), sourceStart);
|
||||
}
|
||||
|
||||
return sourceAnalysisValue({
|
||||
type: "fl_audio_source_analysis",
|
||||
version: SOURCE_ANALYSIS_VERSION,
|
||||
analysis_version: value.analysis_version ?? value.analysisVersion,
|
||||
bpm: value.bpm,
|
||||
base_grid_interval_seconds: value.base_grid_interval_seconds ?? value.baseGridIntervalSeconds,
|
||||
beat_times: sourceTimes(
|
||||
baseBeats.length ? baseBeats : payloadBeats.map((entry) => entry - offset),
|
||||
sourceStart,
|
||||
),
|
||||
downbeat_times: sourceTimes(
|
||||
baseDownbeats.length ? baseDownbeats : payloadDownbeats.map((entry) => entry - offset),
|
||||
sourceStart,
|
||||
),
|
||||
detected_beat_times: sourceTimes(
|
||||
baseDetectedBeats.length
|
||||
? baseDetectedBeats
|
||||
: analysisArray(value, "detected_beat_times", "detectedBeatTimes"),
|
||||
sourceStart,
|
||||
),
|
||||
detected_downbeat_times: sourceTimes(
|
||||
baseDetectedDownbeats.length
|
||||
? baseDetectedDownbeats
|
||||
: analysisArray(value, "detected_downbeat_times", "detectedDownbeatTimes"),
|
||||
sourceStart,
|
||||
),
|
||||
detected_beat_confidences: baseBeatConfidences.length
|
||||
? baseBeatConfidences
|
||||
: analysisArray(value, "detected_beat_confidences", "detectedBeatConfidences"),
|
||||
detected_downbeat_confidences: baseDownbeatConfidences.length
|
||||
? baseDownbeatConfidences
|
||||
: analysisArray(value, "detected_downbeat_confidences", "detectedDownbeatConfidences"),
|
||||
onset_times: sourceTimes(analysisArray(value, "onset_times", "onsetTimes"), sourceStart),
|
||||
drum_times: drums,
|
||||
source_duration: sourceDuration,
|
||||
supports_half_time: false,
|
||||
waveform_preview: value.waveform_preview || value.waveformPreview,
|
||||
waveform_preview_start: sourceStart,
|
||||
cache_key: value.cache_key || value.cacheKey,
|
||||
audio_file: value.audio_file || value.audioFile,
|
||||
detector: value.detector,
|
||||
detector_version: value.detector_version || value.detectorVersion,
|
||||
bpm_source: value.bpm_source || value.bpmSource,
|
||||
analysis_source: value.analysis_source || value.analysisSource,
|
||||
beat_analysis_source: value.beat_analysis_source || value.beatAnalysisSource,
|
||||
analysis_cache_hit: value.analysis_cache_hit ?? value.analysisCacheHit,
|
||||
});
|
||||
}
|
||||
|
||||
function medianInterval(values) {
|
||||
const intervals = values
|
||||
.slice(1)
|
||||
.map((value, index) => value - values[index])
|
||||
.filter((value) => value > EPSILON)
|
||||
.sort((left, right) => left - right);
|
||||
if (!intervals.length) return 0;
|
||||
const middle = Math.floor(intervals.length / 2);
|
||||
return intervals.length % 2
|
||||
? intervals[middle]
|
||||
: (intervals[middle - 1] + intervals[middle]) / 2;
|
||||
}
|
||||
|
||||
function audioViewURL(value) {
|
||||
const match = String(value || "").match(/^(.*?)(?:\s+\[(input|output|temp)\])?$/);
|
||||
const relative = (match?.[1] || "").replace(/\\/g, "/");
|
||||
@@ -933,6 +1100,7 @@ class BeatPromptSequencer {
|
||||
this.hover = null;
|
||||
this.sourceWaveformPreview = null;
|
||||
this.sourceAudioDuration = 0;
|
||||
this.sourceAnalysis = null;
|
||||
this.audioElement = null;
|
||||
this.audioURL = "";
|
||||
this.playbackFrameRequest = null;
|
||||
@@ -952,9 +1120,12 @@ class BeatPromptSequencer {
|
||||
|
||||
const saved = node.properties?.flBeatPromptSequencer || {};
|
||||
const savedCompatible = COMPATIBLE_FORMAT_VERSIONS.has(finiteNumber(saved.formatVersion));
|
||||
this.beatData = savedCompatible ? saved.beatData || null : null;
|
||||
this.sourceAnalysis = savedCompatible ? sourceAnalysisValue(saved.sourceAnalysis) : null;
|
||||
this.beatData = savedCompatible && !this.sourceAnalysis ? saved.beatData || null : null;
|
||||
if (this.beatData) {
|
||||
this.beatData.waveformPreview = normalizeWaveformPreview(this.beatData.waveformPreview);
|
||||
this.sourceAnalysis = sourceAnalysisFromCropPayload(this.beatData);
|
||||
if (this.sourceAnalysis) this.beatData = null;
|
||||
}
|
||||
restoreCachedAudioWidgets(this.widgets, saved);
|
||||
this.dataFresh = false;
|
||||
@@ -1015,7 +1186,7 @@ class BeatPromptSequencer {
|
||||
<span class="flbps-source-label" data-role="source-label">No audio selected</span>
|
||||
<span class="flbps-spacer"></span>
|
||||
<span class="flbps-status" data-role="status">Choose audio to load the timeline</span>
|
||||
<label class="flbps-auto" title="Refresh beat, onset, and drum markers after audio or trim changes">
|
||||
<label class="flbps-auto" title="Refresh beat, onset, and drum markers after the audio or transient source changes">
|
||||
<input data-role="auto-analyze" type="checkbox"> Auto analyze
|
||||
</label>
|
||||
<button class="flbps-button" data-action="analyze" title="Analyze beats, onsets, and drums without queueing the workflow">Analyze</button>
|
||||
@@ -1222,14 +1393,12 @@ class BeatPromptSequencer {
|
||||
this.resnapClipsToGrid();
|
||||
this.zoomToFit();
|
||||
this.markDirty();
|
||||
this.scheduleAnalysis();
|
||||
});
|
||||
bind(this.widgets.sequenceDuration, () => {
|
||||
this.refreshBrowserCrop();
|
||||
this.resnapClipsToGrid();
|
||||
this.zoomToFit();
|
||||
this.markDirty();
|
||||
this.scheduleAnalysis();
|
||||
});
|
||||
bind(this.widgets.audioFile, () => {
|
||||
if (this.widgets.analysisCacheKey) this.widgets.analysisCacheKey.value = "";
|
||||
@@ -1237,11 +1406,17 @@ class BeatPromptSequencer {
|
||||
});
|
||||
bind(this.widgets.trimStartFrame, () => {
|
||||
this.refreshBrowserCrop();
|
||||
});
|
||||
bind(this.widgets.halfTime, () => {
|
||||
this.refreshBrowserCrop();
|
||||
this.resnapClipsToGrid();
|
||||
this.markDirty();
|
||||
});
|
||||
bind(this.widgets.beatOffset, (value) => this.setBeatOffset(value, false, true));
|
||||
bind(this.widgets.analysisSource, () => {
|
||||
this.invalidateAnalysis();
|
||||
this.scheduleAnalysis();
|
||||
});
|
||||
bind(this.widgets.halfTime, () => this.scheduleAnalysis());
|
||||
bind(this.widgets.beatOffset, (value) => this.setBeatOffset(value, false, true));
|
||||
bind(this.widgets.analysisSource, () => this.scheduleAnalysis());
|
||||
bind(this.widgets.beatGridDensity, (value) => this.setBeatGridDensity(value, false));
|
||||
bind(this.widgets.defaultFadeIn, () => this.markDirty());
|
||||
bind(this.widgets.defaultFadeOut, () => this.markDirty());
|
||||
@@ -1265,23 +1440,76 @@ class BeatPromptSequencer {
|
||||
`${sign}${offset} ms · ${frameSign}${frames.toFixed(2)} fr`;
|
||||
}
|
||||
|
||||
cropBounds() {
|
||||
const sourceDuration = this.sourceDurationSeconds();
|
||||
const start = clamp(this.cropStartSeconds(), 0, sourceDuration);
|
||||
const duration = Math.min(this.cropDurationSeconds(), Math.max(0, sourceDuration - start));
|
||||
return { start, end: start + duration, duration, sourceDuration };
|
||||
}
|
||||
|
||||
sourceBeatValues() {
|
||||
const source = this.sourceAnalysis;
|
||||
if (!source?.beatTimes?.length) return null;
|
||||
if (!source.supportsHalfTime || !this.widgets.halfTime?.value) {
|
||||
const interval = source.baseGridIntervalSeconds ||
|
||||
medianInterval(source.beatTimes) ||
|
||||
(source.bpm > 0 ? 60 / source.bpm : 0);
|
||||
return {
|
||||
beats: source.beatTimes,
|
||||
downbeats: source.downbeatTimes,
|
||||
detectedBeats: source.detectedBeatTimes,
|
||||
detectedDownbeats: source.detectedDownbeatTimes,
|
||||
beatConfidences: source.detectedBeatConfidences,
|
||||
downbeatConfidences: source.detectedDownbeatConfidences,
|
||||
interval,
|
||||
bpm: interval > 0 ? 60 / interval : source.bpm,
|
||||
};
|
||||
}
|
||||
|
||||
const retained = source.beatTimes.map((_, index) => index).filter((index) => index % 2 === 0);
|
||||
const retainedSet = new Set(retained);
|
||||
const beats = retained.map((index) => source.beatTimes[index]);
|
||||
const beatConfidences = retained
|
||||
.filter((index) => index < source.detectedBeatConfidences.length)
|
||||
.map((index) => source.detectedBeatConfidences[index]);
|
||||
const downbeats = [];
|
||||
const downbeatConfidences = [];
|
||||
for (let index = 0; index < source.downbeatTimes.length; index++) {
|
||||
const downbeat = source.downbeatTimes[index];
|
||||
let nearest = 0;
|
||||
for (let position = 1; position < source.beatTimes.length; position++) {
|
||||
if (Math.abs(source.beatTimes[position] - downbeat) <
|
||||
Math.abs(source.beatTimes[nearest] - downbeat)) {
|
||||
nearest = position;
|
||||
}
|
||||
}
|
||||
if (!retainedSet.has(nearest)) continue;
|
||||
downbeats.push(downbeat);
|
||||
if (index < source.detectedDownbeatConfidences.length) {
|
||||
downbeatConfidences.push(source.detectedDownbeatConfidences[index]);
|
||||
}
|
||||
}
|
||||
const interval = medianInterval(beats);
|
||||
return {
|
||||
beats,
|
||||
downbeats,
|
||||
detectedBeats: beats,
|
||||
detectedDownbeats: downbeats,
|
||||
beatConfidences,
|
||||
downbeatConfidences,
|
||||
interval,
|
||||
bpm: interval > 0 ? 60 / interval : source.bpm,
|
||||
};
|
||||
}
|
||||
|
||||
baseGridIntervalSeconds() {
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
if (sourceValues) return sourceValues.interval;
|
||||
const values = this.beatData?.baseBeatTimes || [];
|
||||
const configured = finiteNumber(this.beatData?.baseGridIntervalSeconds);
|
||||
if (configured > 0) return configured;
|
||||
if (values.length > 1) {
|
||||
const intervals = values
|
||||
.slice(1)
|
||||
.map((value, index) => finiteNumber(value) - finiteNumber(values[index]))
|
||||
.filter((value) => value > EPSILON)
|
||||
.sort((left, right) => left - right);
|
||||
if (intervals.length) {
|
||||
const middle = Math.floor(intervals.length / 2);
|
||||
return intervals.length % 2
|
||||
? intervals[middle]
|
||||
: (intervals[middle - 1] + intervals[middle]) / 2;
|
||||
}
|
||||
}
|
||||
const interval = medianInterval(values);
|
||||
if (interval > 0) return interval;
|
||||
const bpm = finiteNumber(this.beatData?.bpm);
|
||||
return bpm > 0 ? 60 / bpm : 0;
|
||||
}
|
||||
@@ -1294,7 +1522,7 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
baseGridTimes() {
|
||||
const values = this.beatData?.baseBeatTimes || [];
|
||||
const values = this.sourceBeatValues()?.beats || this.beatData?.baseBeatTimes || [];
|
||||
if (this.beatGridDensity() === "every_2_beats") {
|
||||
return values.filter((_, index) => index % 2 === 0);
|
||||
}
|
||||
@@ -1302,16 +1530,17 @@ class BeatPromptSequencer {
|
||||
const result = [];
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
result.push(values[index]);
|
||||
if (index + 1 < values.length) {
|
||||
result.push((values[index] + values[index + 1]) / 2);
|
||||
}
|
||||
if (index + 1 < values.length) result.push((values[index] + values[index + 1]) / 2);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
gridBeatTimes(offsetMs = this.beatOffsetMs()) {
|
||||
const values = this.baseGridTimes();
|
||||
const duration = Math.max(0, finiteNumber(this.beatData?.audioDuration));
|
||||
const sourceMode = Boolean(this.sourceAnalysis);
|
||||
const duration = sourceMode
|
||||
? this.sourceDurationSeconds()
|
||||
: Math.max(0, finiteNumber(this.beatData?.audioDuration));
|
||||
const interval = this.gridIntervalSeconds();
|
||||
if (!values.length || !(duration > 0) || !(interval > 0)) return [];
|
||||
const offset = finiteNumber(offsetMs) / 1000;
|
||||
@@ -1328,13 +1557,19 @@ class BeatPromptSequencer {
|
||||
if (beatTime >= 0) result.push(beatTime);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
if (!sourceMode) return result;
|
||||
const crop = this.cropBounds();
|
||||
return cropTimes(result, crop.start, crop.end);
|
||||
}
|
||||
|
||||
gridDownbeatTimes(offsetMs = this.beatOffsetMs()) {
|
||||
const beats = this.beatData?.baseBeatTimes || [];
|
||||
const downbeats = this.beatData?.baseDownbeatTimes || [];
|
||||
const duration = Math.max(0, finiteNumber(this.beatData?.audioDuration));
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
const beats = sourceValues?.beats || this.beatData?.baseBeatTimes || [];
|
||||
const downbeats = sourceValues?.downbeats || this.beatData?.baseDownbeatTimes || [];
|
||||
const sourceMode = Boolean(this.sourceAnalysis);
|
||||
const duration = sourceMode
|
||||
? this.sourceDurationSeconds()
|
||||
: Math.max(0, finiteNumber(this.beatData?.audioDuration));
|
||||
const offset = finiteNumber(offsetMs) / 1000;
|
||||
if (!beats.length || !downbeats.length || !(duration > 0)) return [];
|
||||
const density = this.beatGridDensity();
|
||||
@@ -1349,7 +1584,82 @@ class BeatPromptSequencer {
|
||||
const shifted = finiteNumber(beats[nearest]) + offset;
|
||||
if (shifted >= 0 && shifted < duration) result.push(shifted);
|
||||
}
|
||||
return result;
|
||||
if (!sourceMode) return result;
|
||||
const crop = this.cropBounds();
|
||||
return cropTimes(result, crop.start, crop.end);
|
||||
}
|
||||
|
||||
projectSourceAnalysis() {
|
||||
const source = this.sourceAnalysis;
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
if (!source || !sourceValues) return false;
|
||||
const crop = this.cropBounds();
|
||||
const [detectedBeatTimes, detectedBeatConfidences] = cropTimesWithValues(
|
||||
sourceValues.detectedBeats,
|
||||
sourceValues.beatConfidences,
|
||||
crop.start,
|
||||
crop.end,
|
||||
);
|
||||
const [detectedDownbeatTimes, detectedDownbeatConfidences] = cropTimesWithValues(
|
||||
sourceValues.detectedDownbeats,
|
||||
sourceValues.downbeatConfidences,
|
||||
crop.start,
|
||||
crop.end,
|
||||
);
|
||||
const drums = { ...(source.drumTimes || {}) };
|
||||
for (const key of ["kick_times", "snare_times", "hihat_times"]) {
|
||||
drums[key] = cropTimes(drums[key] || [], crop.start, crop.end);
|
||||
}
|
||||
drums.duration = crop.duration;
|
||||
drums.total_kicks = drums.kick_times.length;
|
||||
drums.total_snares = drums.snare_times.length;
|
||||
drums.total_hihats = drums.hihat_times.length;
|
||||
|
||||
const interval = this.gridIntervalSeconds();
|
||||
this.beatData = {
|
||||
bpm: sourceValues.bpm,
|
||||
gridBpm: interval > 0 ? 60 / interval : sourceValues.bpm,
|
||||
baseGridIntervalSeconds: sourceValues.interval,
|
||||
gridIntervalSeconds: interval,
|
||||
beatGridDensity: this.beatGridDensity(),
|
||||
baseBeatTimes: cropTimes(sourceValues.beats, crop.start, crop.end),
|
||||
baseDetectedBeatTimes: detectedBeatTimes,
|
||||
baseDownbeatTimes: cropTimes(sourceValues.downbeats, crop.start, crop.end),
|
||||
baseDetectedDownbeatTimes: detectedDownbeatTimes,
|
||||
baseDetectedBeatConfidences: detectedBeatConfidences,
|
||||
baseDetectedDownbeatConfidences: detectedDownbeatConfidences,
|
||||
beatTimes: this.gridBeatTimes(),
|
||||
downbeatTimes: this.gridDownbeatTimes(),
|
||||
detectedBeatTimes,
|
||||
detectedDownbeatTimes,
|
||||
detectedBeatConfidences,
|
||||
detectedDownbeatConfidences,
|
||||
onsetTimes: cropTimes(source.onsetTimes, crop.start, crop.end),
|
||||
drumTimes: drums,
|
||||
audioDuration: crop.duration,
|
||||
sourceDuration: crop.sourceDuration,
|
||||
sourceStart: crop.start,
|
||||
fps: this.fps(),
|
||||
waveformPreview: cropWaveformPreview(
|
||||
source.waveformPreview,
|
||||
crop.start - source.waveformPreviewStart,
|
||||
crop.duration,
|
||||
) ||
|
||||
cropWaveformPreview(this.sourceWaveformPreview, crop.start, crop.duration),
|
||||
cacheKey: source.cacheKey,
|
||||
audioFile: source.audioFile,
|
||||
detector: source.detector,
|
||||
detectorVersion: source.detectorVersion,
|
||||
bpmSource: source.bpmSource,
|
||||
analysisSource: source.analysisSource,
|
||||
beatAnalysisSource: source.beatAnalysisSource,
|
||||
analysisCacheHit: source.analysisCacheHit,
|
||||
beatOffsetMs: this.beatOffsetMs(),
|
||||
};
|
||||
this.updateTransportTime();
|
||||
this.refreshBeatStatus();
|
||||
this.scheduleDraw();
|
||||
return true;
|
||||
}
|
||||
|
||||
applyBeatOffset() {
|
||||
@@ -1361,6 +1671,7 @@ class BeatPromptSequencer {
|
||||
if (this.controls?.beatGridDensity) {
|
||||
this.controls.beatGridDensity.value = density;
|
||||
}
|
||||
if (this.projectSourceAnalysis()) return;
|
||||
if (!this.beatData) {
|
||||
this.scheduleDraw();
|
||||
return;
|
||||
@@ -1405,6 +1716,9 @@ class BeatPromptSequencer {
|
||||
saveViewState() {
|
||||
this.node.properties = this.node.properties || {};
|
||||
const savedBeatData = this.beatData ? { ...this.beatData, waveformPreview: null } : null;
|
||||
const savedSourceAnalysis = this.sourceAnalysis
|
||||
? { ...this.sourceAnalysis, waveformPreview: null }
|
||||
: null;
|
||||
const previous = { ...(this.node.properties.flBeatPromptSequencer || {}) };
|
||||
delete previous.magnetMode;
|
||||
delete previous.snapMode;
|
||||
@@ -1412,7 +1726,8 @@ class BeatPromptSequencer {
|
||||
this.node.properties.flBeatPromptSequencer = {
|
||||
...previous,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
beatData: savedBeatData,
|
||||
beatData: this.sourceAnalysis ? null : savedBeatData,
|
||||
sourceAnalysis: savedSourceAnalysis,
|
||||
viewStart: this.viewStart,
|
||||
viewEnd: this.viewEnd,
|
||||
autoAnalyze: this.autoAnalyze,
|
||||
@@ -1428,10 +1743,44 @@ class BeatPromptSequencer {
|
||||
return this.trimStartFrame() / this.fps();
|
||||
}
|
||||
|
||||
sourceDurationSeconds() {
|
||||
return Math.max(
|
||||
0,
|
||||
finiteNumber(
|
||||
this.sourceAudioDuration || this.sourceAnalysis?.duration || this.beatData?.sourceDuration,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
cropDurationSeconds() {
|
||||
const configured = this.configuredFrameCount();
|
||||
if (configured > 0) return configured / this.fps();
|
||||
return Math.max(0, this.sourceAudioDuration - this.cropStartSeconds());
|
||||
return Math.max(0, this.sourceDurationSeconds() - this.cropStartSeconds());
|
||||
}
|
||||
|
||||
waveformSource() {
|
||||
if (this.sourceAnalysis?.waveformPreview) {
|
||||
const analysisWaveform = {
|
||||
preview: this.sourceAnalysis.waveformPreview,
|
||||
start: this.sourceAnalysis.waveformPreviewStart,
|
||||
};
|
||||
const crop = this.cropBounds();
|
||||
if (analysisWaveform.start <= crop.start + EPSILON &&
|
||||
analysisWaveform.start + analysisWaveform.preview.duration >= crop.end - EPSILON) {
|
||||
return analysisWaveform;
|
||||
}
|
||||
if (!this.sourceWaveformPreview) return analysisWaveform;
|
||||
}
|
||||
if (this.sourceWaveformPreview) {
|
||||
return { preview: this.sourceWaveformPreview, start: 0 };
|
||||
}
|
||||
if (this.beatData?.waveformPreview) {
|
||||
return {
|
||||
preview: this.beatData.waveformPreview,
|
||||
start: finiteNumber(this.beatData.sourceStart),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setStatus(text, state = "", progress = null) {
|
||||
@@ -1476,7 +1825,13 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
invalidateAnalysis() {
|
||||
if (!this.beatData) return;
|
||||
this.sourceAnalysis = null;
|
||||
if (!this.beatData) {
|
||||
this.dataFresh = false;
|
||||
this.setStatus("Audio changed · analysis pending", "cached");
|
||||
this.scheduleDraw();
|
||||
return;
|
||||
}
|
||||
this.beatData.baseBeatTimes = [];
|
||||
this.beatData.baseDetectedBeatTimes = [];
|
||||
this.beatData.baseDownbeatTimes = [];
|
||||
@@ -1497,6 +1852,10 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
refreshBrowserCrop() {
|
||||
if (!this.sourceAnalysis && this.beatData?.beatTimes?.length) {
|
||||
this.sourceAnalysis = sourceAnalysisFromCropPayload(this.beatData);
|
||||
}
|
||||
if (this.projectSourceAnalysis()) return;
|
||||
if (!this.sourceWaveformPreview || !(this.sourceAudioDuration > 0)) {
|
||||
this.updateTransportTime();
|
||||
return;
|
||||
@@ -1527,6 +1886,7 @@ class BeatPromptSequencer {
|
||||
this.sourceWaveformPreview = null;
|
||||
this.sourceAudioDuration = 0;
|
||||
if (!filename) {
|
||||
this.sourceAnalysis = null;
|
||||
this.beatData = null;
|
||||
if (this.widgets.analysisCacheKey) this.widgets.analysisCacheKey.value = "";
|
||||
this.audioElement = null;
|
||||
@@ -1585,8 +1945,11 @@ class BeatPromptSequencer {
|
||||
this.startPlaybackLoop();
|
||||
});
|
||||
this.audioElement.addEventListener("ended", () => this.loopPlayback(true));
|
||||
if (this.sourceAnalysis?.audioFile !== filename ||
|
||||
this.sourceAnalysis?.analysisSource !== (this.widgets.analysisSource?.value || "mix")) {
|
||||
this.invalidateAnalysis();
|
||||
}
|
||||
this.refreshBrowserCrop();
|
||||
this.invalidateAnalysis();
|
||||
this.zoomToFit(false);
|
||||
this.scheduleAnalysis(0);
|
||||
} catch (error) {
|
||||
@@ -1634,10 +1997,14 @@ class BeatPromptSequencer {
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || `Analysis failed (${response.status}).`);
|
||||
if (request !== this.analysisRequest) return;
|
||||
this.applyAnalysis(payload, true);
|
||||
const completeSource = this.applyAnalysis(payload, true);
|
||||
if (this.migrationPending) this.loadTimeline();
|
||||
this.resnapClipsToGrid();
|
||||
this.clearError();
|
||||
if (completeSource) {
|
||||
this.clearError();
|
||||
} else {
|
||||
this.showError("Restart ComfyUI and refresh the browser to load full-source beat analysis.");
|
||||
}
|
||||
this.saveViewState();
|
||||
analysisCompleted = true;
|
||||
} catch (error) {
|
||||
@@ -1653,6 +2020,41 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
applyAnalysis(payload, fresh) {
|
||||
const sourceAnalysis = sourceAnalysisValue(payload.source_analysis);
|
||||
if (sourceAnalysis) {
|
||||
this.sourceAnalysis = sourceAnalysis;
|
||||
const audioFile = String(
|
||||
sourceAnalysis.audioFile || payload.audio_file || this.widgets.audioFile?.value || "",
|
||||
);
|
||||
if (this.widgets.audioFile && !this.widgets.audioFile.value && audioFile) {
|
||||
this.widgets.audioFile.value = audioFile;
|
||||
}
|
||||
if (this.widgets.analysisCacheKey) {
|
||||
this.widgets.analysisCacheKey.value = sourceAnalysis.cacheKey;
|
||||
}
|
||||
this.dataFresh = fresh && !sourceAnalysis.analysisCacheHit;
|
||||
this.applyBeatOffset();
|
||||
return true;
|
||||
}
|
||||
|
||||
const partialSource = sourceAnalysisFromCropPayload(payload);
|
||||
if (partialSource) {
|
||||
this.sourceAnalysis = partialSource;
|
||||
const audioFile = String(
|
||||
partialSource.audioFile || payload.audio_file || this.widgets.audioFile?.value || "",
|
||||
);
|
||||
if (this.widgets.audioFile && !this.widgets.audioFile.value && audioFile) {
|
||||
this.widgets.audioFile.value = audioFile;
|
||||
}
|
||||
if (this.widgets.analysisCacheKey) {
|
||||
this.widgets.analysisCacheKey.value = partialSource.cacheKey;
|
||||
}
|
||||
this.dataFresh = fresh && !partialSource.analysisCacheHit;
|
||||
this.applyBeatOffset();
|
||||
return false;
|
||||
}
|
||||
|
||||
this.sourceAnalysis = null;
|
||||
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(
|
||||
@@ -1723,6 +2125,7 @@ class BeatPromptSequencer {
|
||||
}
|
||||
this.dataFresh = fresh && !this.beatData.analysisCacheHit;
|
||||
this.applyBeatOffset();
|
||||
return false;
|
||||
}
|
||||
|
||||
updatePlayButton() {
|
||||
@@ -2325,20 +2728,44 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
beatFrames() {
|
||||
if (this.sourceAnalysis) {
|
||||
return this.gridBeatTimes().map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
return (this.beatData?.beatTimes || []).map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
|
||||
downbeatFrames() {
|
||||
if (this.sourceAnalysis) {
|
||||
return this.gridDownbeatTimes().map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
return (this.beatData?.downbeatTimes || []).map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
|
||||
detectedBeatFrames() {
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
if (sourceValues) {
|
||||
const crop = this.cropBounds();
|
||||
return sourceValues.detectedBeats
|
||||
.filter((seconds) => seconds >= crop.start && seconds < crop.end)
|
||||
.map((seconds) => sourceTimeToLocalFrame(seconds, crop.start, this.fps()));
|
||||
}
|
||||
return (this.beatData?.detectedBeatTimes || []).map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
|
||||
detectedBeatMarkers() {
|
||||
const times = this.beatData?.detectedBeatTimes || [];
|
||||
const confidences = this.beatData?.detectedBeatConfidences || [];
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
const crop = sourceValues ? this.cropBounds() : null;
|
||||
const [times, confidences] = sourceValues
|
||||
? cropTimesWithValues(
|
||||
sourceValues.detectedBeats,
|
||||
sourceValues.beatConfidences,
|
||||
crop.start,
|
||||
crop.end,
|
||||
)
|
||||
: [
|
||||
this.beatData?.detectedBeatTimes || [],
|
||||
this.beatData?.detectedBeatConfidences || [],
|
||||
];
|
||||
const intervals = times.slice(1).map((value, index) => value - times[index]).filter((value) => value > EPSILON);
|
||||
const sorted = [...intervals].sort((left, right) => left - right);
|
||||
const median = sorted.length ? sorted[Math.floor(sorted.length / 2)] : 0;
|
||||
@@ -2353,8 +2780,19 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
detectedDownbeatMarkers() {
|
||||
const times = this.beatData?.detectedDownbeatTimes || [];
|
||||
const confidences = this.beatData?.detectedDownbeatConfidences || [];
|
||||
const sourceValues = this.sourceBeatValues();
|
||||
const crop = sourceValues ? this.cropBounds() : null;
|
||||
const [times, confidences] = sourceValues
|
||||
? cropTimesWithValues(
|
||||
sourceValues.detectedDownbeats,
|
||||
sourceValues.downbeatConfidences,
|
||||
crop.start,
|
||||
crop.end,
|
||||
)
|
||||
: [
|
||||
this.beatData?.detectedDownbeatTimes || [],
|
||||
this.beatData?.detectedDownbeatConfidences || [],
|
||||
];
|
||||
return times.map((seconds, index) => ({
|
||||
frame: Math.round(seconds * this.fps()),
|
||||
confidence: clamp(finiteNumber(confidences[index], 1), 0, 1),
|
||||
@@ -2363,6 +2801,12 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
onsetFrames() {
|
||||
if (this.sourceAnalysis) {
|
||||
const crop = this.cropBounds();
|
||||
return this.sourceAnalysis.onsetTimes
|
||||
.filter((seconds) => seconds >= crop.start && seconds < crop.end)
|
||||
.map((seconds) => sourceTimeToLocalFrame(seconds, crop.start, this.fps()));
|
||||
}
|
||||
return (this.beatData?.onsetTimes || []).map((seconds) => Math.round(seconds * this.fps()));
|
||||
}
|
||||
|
||||
@@ -2737,12 +3181,10 @@ class BeatPromptSequencer {
|
||||
this.resnapClipsToGrid(croppedMarkers);
|
||||
this.playheadFrame = 0;
|
||||
this.refreshBrowserCrop();
|
||||
this.invalidateAnalysis();
|
||||
this.serialize();
|
||||
this.syncInspector();
|
||||
this.zoomToFit(false);
|
||||
this.markDirty();
|
||||
this.scheduleAnalysis(0);
|
||||
}
|
||||
|
||||
setAudioIn(frame) {
|
||||
@@ -3165,7 +3607,6 @@ class BeatPromptSequencer {
|
||||
);
|
||||
}
|
||||
this.refreshBrowserCrop();
|
||||
this.invalidateAnalysis();
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
@@ -3201,7 +3642,6 @@ class BeatPromptSequencer {
|
||||
end: trimHit.handles.end,
|
||||
sourceFrames: trimHit.handles.sourceFrames,
|
||||
},
|
||||
beatFrames: this.beatFrames(),
|
||||
pointerStartFrame: this.sourceFrameAtX(x),
|
||||
active: false,
|
||||
};
|
||||
@@ -3473,7 +3913,6 @@ class BeatPromptSequencer {
|
||||
|
||||
onPointerUp(event) {
|
||||
if (!this.drag) return;
|
||||
const finishedDrag = this.drag;
|
||||
const trimChanged = this.drag.type === "trim-start" ||
|
||||
this.drag.type === "trim-end" ||
|
||||
this.drag.type === "trim-move";
|
||||
@@ -3485,17 +3924,8 @@ class BeatPromptSequencer {
|
||||
if (this.canvas.hasPointerCapture(event.pointerId)) this.canvas.releasePointerCapture(event.pointerId);
|
||||
if (changed) {
|
||||
if (trimChanged) {
|
||||
const start = this.trimStartFrame();
|
||||
const duration = this.sequenceFrameCount();
|
||||
const markers = (finishedDrag.beatFrames || [])
|
||||
.map((marker) => finishedDrag.original.start + marker - start)
|
||||
.filter((marker) => marker >= 0 && marker <= duration);
|
||||
if (markers.length) {
|
||||
markers.push(0, duration);
|
||||
this.resnapClipsToGrid([...new Set(markers)].sort((left, right) => left - right));
|
||||
}
|
||||
this.resnapClipsToGrid();
|
||||
this.zoomToFit(false);
|
||||
this.scheduleAnalysis(0);
|
||||
} else if (viewChanged) {
|
||||
this.saveViewState();
|
||||
} else {
|
||||
@@ -3713,7 +4143,8 @@ class BeatPromptSequencer {
|
||||
drawWaveformLane(ctx, width, top, bottom) {
|
||||
const right = width - TIMELINE_RIGHT;
|
||||
const center = (top + bottom) / 2;
|
||||
const preview = this.beatData?.waveformPreview;
|
||||
const waveform = this.waveformSource();
|
||||
const preview = waveform?.preview;
|
||||
|
||||
ctx.fillStyle = "#14191e";
|
||||
ctx.fillRect(TIMELINE_LEFT, top, right - TIMELINE_LEFT, bottom - top);
|
||||
@@ -3746,9 +4177,11 @@ class BeatPromptSequencer {
|
||||
}
|
||||
|
||||
const binCount = preview.peaks.length / 2;
|
||||
const waveformEndFrame = preview.duration * this.fps();
|
||||
const visibleStart = Math.max(this.viewStart, 0);
|
||||
const visibleEnd = Math.min(this.viewEnd, waveformEndFrame);
|
||||
const cropStart = this.cropStartSeconds();
|
||||
const waveformStartFrame = (waveform.start - cropStart) * this.fps();
|
||||
const waveformEndFrame = (waveform.start + preview.duration - cropStart) * this.fps();
|
||||
const visibleStart = Math.max(this.viewStart, waveformStartFrame, 0);
|
||||
const visibleEnd = Math.min(this.viewEnd, waveformEndFrame, this.sequenceFrameCount());
|
||||
if (!(visibleEnd > visibleStart)) return;
|
||||
|
||||
const startX = Math.max(TIMELINE_LEFT, Math.floor(this.frameToX(visibleStart, width)));
|
||||
@@ -3760,14 +4193,13 @@ class BeatPromptSequencer {
|
||||
for (let x = startX; x <= endX; x++) {
|
||||
const firstFrame = this.frameAtX(x);
|
||||
const lastFrame = this.frameAtX(Math.min(endX, x + 1));
|
||||
const firstBin = clamp(
|
||||
Math.floor((firstFrame / this.fps() / preview.duration) * binCount),
|
||||
0,
|
||||
binCount - 1,
|
||||
);
|
||||
const lastBin = clamp(
|
||||
Math.ceil((lastFrame / this.fps() / preview.duration) * binCount),
|
||||
firstBin + 1,
|
||||
const [firstBin, lastBin] = waveformBinRange(
|
||||
firstFrame,
|
||||
lastFrame,
|
||||
cropStart,
|
||||
this.fps(),
|
||||
waveform.start,
|
||||
preview.duration,
|
||||
binCount,
|
||||
);
|
||||
let minimum = preview.scale;
|
||||
@@ -3786,7 +4218,7 @@ class BeatPromptSequencer {
|
||||
this.hover.x < TIMELINE_LEFT || this.hover.x > right) {
|
||||
return;
|
||||
}
|
||||
const frame = clamp(Math.round(this.frameAtX(this.hover.x)), 0, Math.round(waveformEndFrame));
|
||||
const frame = clamp(Math.round(this.frameAtX(this.hover.x)), 0, this.sequenceFrameCount());
|
||||
const seconds = frame / this.fps();
|
||||
const x = this.frameToX(frame, width);
|
||||
ctx.strokeStyle = "rgba(251,191,36,.42)";
|
||||
@@ -4866,6 +5298,7 @@ function migrateRemovedBpmMethod(graphData) {
|
||||
node.properties.flBeatPromptSequencer = {
|
||||
...(node.properties.flBeatPromptSequencer || {}),
|
||||
beatData: null,
|
||||
sourceAnalysis: null,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
};
|
||||
}
|
||||
@@ -4925,6 +5358,7 @@ app.registerExtension({
|
||||
delete savedSequencer.snapMode;
|
||||
if (!COMPATIBLE_FORMAT_VERSIONS.has(previousFormat)) {
|
||||
savedSequencer.beatData = null;
|
||||
savedSequencer.sourceAnalysis = null;
|
||||
savedSequencer.viewStart = 0;
|
||||
savedSequencer.viewEnd = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export function cropTimes(values, start, end) {
|
||||
return (values || [])
|
||||
.filter((value) => value >= start && value < end)
|
||||
.map((value) => value - start);
|
||||
}
|
||||
|
||||
export function cropTimesWithValues(times, values, start, end) {
|
||||
const croppedTimes = [];
|
||||
const croppedValues = [];
|
||||
for (let index = 0; index < (times || []).length; index++) {
|
||||
const value = times[index];
|
||||
if (value < start || value >= end) continue;
|
||||
croppedTimes.push(value - start);
|
||||
if (index < (values || []).length) croppedValues.push(values[index]);
|
||||
}
|
||||
return [croppedTimes, croppedValues];
|
||||
}
|
||||
|
||||
export function sourceTimes(values, sourceStart) {
|
||||
return (values || []).map((value) => value + sourceStart);
|
||||
}
|
||||
|
||||
export function sourceTimeAtLocalFrame(frame, cropStart, fps) {
|
||||
return cropStart + frame / fps;
|
||||
}
|
||||
|
||||
export function sourceTimeToLocalFrame(sourceTime, cropStart, fps) {
|
||||
return Math.round((sourceTime - cropStart) * fps);
|
||||
}
|
||||
|
||||
export function waveformBinRange(
|
||||
firstFrame,
|
||||
lastFrame,
|
||||
cropStart,
|
||||
fps,
|
||||
previewStart,
|
||||
previewDuration,
|
||||
binCount,
|
||||
) {
|
||||
const firstSeconds = sourceTimeAtLocalFrame(firstFrame, cropStart, fps) - previewStart;
|
||||
const lastSeconds = sourceTimeAtLocalFrame(lastFrame, cropStart, fps) - previewStart;
|
||||
const firstBin = Math.max(
|
||||
0,
|
||||
Math.min(binCount - 1, Math.floor(firstSeconds / previewDuration * binCount)),
|
||||
);
|
||||
const lastBin = Math.max(
|
||||
firstBin + 1,
|
||||
Math.min(binCount, Math.ceil(lastSeconds / previewDuration * binCount)),
|
||||
);
|
||||
return [firstBin, lastBin];
|
||||
}
|
||||
Reference in New Issue
Block a user