danceflow-stemlab 1.1.2__tar.gz → 1.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/PKG-INFO +9 -1
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/README.md +8 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/PKG-INFO +9 -1
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/__init__.py +1 -1
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/loops.py +172 -29
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/runner.py +3 -2
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/cli.py +13 -4
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/bridge.py +11 -4
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/server.py +15 -5
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/tasks.py +10 -3
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/pipeline.py +3 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/types.py +1 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_bridge.py +40 -2
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_distribution_identity.py +1 -1
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_loop_integration.py +7 -2
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_loops.py +63 -1
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.agents/plugins/marketplace.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.github/workflows/codex-plugin.yml +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.github/workflows/release.yml +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/LICENSE +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/MANIFEST.in +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/docs/codex-plugin.md +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/docs/codex-publishing.md +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/mcp-registry/server.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/.codex-plugin/plugin.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/.mcp.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/README.md +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/mcp.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/plugin.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/skills/stemlab/SKILL.md +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/skills/stemlab/references/tools.md +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/pyproject.toml +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/build_codex_plugin.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/capture_loop_workflow.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/check_codex_timeline.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/loop_demo.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/prepare_codex_marketplace.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/publish_mcp_registry.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/setup.cfg +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/SOURCES.txt +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/dependency_links.txt +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/entry_points.txt +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/requires.txt +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/top_level.txt +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/__main__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/__init__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/harmony.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/lyrics.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/registry.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/rhythm.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/semantic_audio.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/semantic_text.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/song_map.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/sonic.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/structure.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/tempo_regimes.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/transcription.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/audio.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/__init__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/base.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beatnet.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beatthis.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beattransformer.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/common.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/bootstrap.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/branding.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/canonical.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/__init__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/__main__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/jobs.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/paths.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/timeline.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/worker.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/manifest.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/models.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/__init__.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/base.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/bsroformer.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/common.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/demucs.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/openunmix.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/scnet.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/server.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/sonic_visualiser.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/spectrogram.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/speech.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/util.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/vamp.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/vamp_runtime.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/app.js +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/arcadians-cover.jpg +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/arcadians-reference.json +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/index.html +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/style.css +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/timeline.css +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/webui.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_analysis_registry.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_arcadians_fixture.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_audio_normalization.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_beat_consensus.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_beatnet_compat.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_branding.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_canonical.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_canonical_extended.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_jobs.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_package.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_protocol.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_release.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_timeline.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_harmony.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_lyrics.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_sonic_rhythm.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_manifest.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_models.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_repository_hygiene.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_server.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_song_map.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_sonic_visualiser.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_sonic_visualiser_workspace.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_speech_repetition.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_vamp.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_vamp_runtime.py +0 -0
- {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_web_frontend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: danceflow-stemlab
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: StemLab audio timing analysis and loopable sections for creating Shorts videos; DanceFlow and WordPress DanceMoves
|
|
5
5
|
Author: Kieran Simkin
|
|
6
6
|
Maintainer: Kieran Simkin
|
|
@@ -285,6 +285,14 @@ stemlab analyze master.wav -o analysis-master --profile practical --export-loops
|
|
|
285
285
|
|
|
286
286
|
# Reuse existing separation, structure and beat results; no models rerun.
|
|
287
287
|
stemlab loops analysis-master --export-loops
|
|
288
|
+
|
|
289
|
+
# Keep all candidates within a chosen short-video edit budget, in seconds.
|
|
290
|
+
stemlab loops analysis-master --max-seconds 30 --per-section 3
|
|
291
|
+
|
|
292
|
+
# Audition possible new cut pairs anywhere in the song, even where the passage
|
|
293
|
+
# does not already repeat. These near-misses are NOT accepted seamless loops.
|
|
294
|
+
stemlab loops analysis-master --mode exploratory --search-scope whole_song \
|
|
295
|
+
--max-seconds 30 --per-section 20 --exploratory-algorithm spectral_context
|
|
288
296
|
```
|
|
289
297
|
|
|
290
298
|
In the existing analysis frontend, choose a highlighted loop region or the Loop
|
|
@@ -199,6 +199,14 @@ stemlab analyze master.wav -o analysis-master --profile practical --export-loops
|
|
|
199
199
|
|
|
200
200
|
# Reuse existing separation, structure and beat results; no models rerun.
|
|
201
201
|
stemlab loops analysis-master --export-loops
|
|
202
|
+
|
|
203
|
+
# Keep all candidates within a chosen short-video edit budget, in seconds.
|
|
204
|
+
stemlab loops analysis-master --max-seconds 30 --per-section 3
|
|
205
|
+
|
|
206
|
+
# Audition possible new cut pairs anywhere in the song, even where the passage
|
|
207
|
+
# does not already repeat. These near-misses are NOT accepted seamless loops.
|
|
208
|
+
stemlab loops analysis-master --mode exploratory --search-scope whole_song \
|
|
209
|
+
--max-seconds 30 --per-section 20 --exploratory-algorithm spectral_context
|
|
202
210
|
```
|
|
203
211
|
|
|
204
212
|
In the existing analysis frontend, choose a highlighted loop region or the Loop
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: danceflow-stemlab
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: StemLab audio timing analysis and loopable sections for creating Shorts videos; DanceFlow and WordPress DanceMoves
|
|
5
5
|
Author: Kieran Simkin
|
|
6
6
|
Maintainer: Kieran Simkin
|
|
@@ -285,6 +285,14 @@ stemlab analyze master.wav -o analysis-master --profile practical --export-loops
|
|
|
285
285
|
|
|
286
286
|
# Reuse existing separation, structure and beat results; no models rerun.
|
|
287
287
|
stemlab loops analysis-master --export-loops
|
|
288
|
+
|
|
289
|
+
# Keep all candidates within a chosen short-video edit budget, in seconds.
|
|
290
|
+
stemlab loops analysis-master --max-seconds 30 --per-section 3
|
|
291
|
+
|
|
292
|
+
# Audition possible new cut pairs anywhere in the song, even where the passage
|
|
293
|
+
# does not already repeat. These near-misses are NOT accepted seamless loops.
|
|
294
|
+
stemlab loops analysis-master --mode exploratory --search-scope whole_song \
|
|
295
|
+
--max-seconds 30 --per-section 20 --exploratory-algorithm spectral_context
|
|
288
296
|
```
|
|
289
297
|
|
|
290
298
|
In the existing analysis frontend, choose a highlighted loop region or the Loop
|
|
@@ -26,6 +26,10 @@ from stemlab.util import sha256_file
|
|
|
26
26
|
@dataclass(frozen=True)
|
|
27
27
|
class LoopConfig:
|
|
28
28
|
max_bars: int = 16
|
|
29
|
+
max_seconds: float | None = None
|
|
30
|
+
mode: str = "strict"
|
|
31
|
+
exploratory_algorithm: str = "spectral_context"
|
|
32
|
+
search_scope: str = "sections"
|
|
29
33
|
loops_per_section: int = 1
|
|
30
34
|
snap_ms: float = 5.0
|
|
31
35
|
vocal_guard_ms: float = 80.0
|
|
@@ -39,12 +43,27 @@ class LoopConfig:
|
|
|
39
43
|
|
|
40
44
|
def __post_init__(self):
|
|
41
45
|
for name, value in asdict(self).items():
|
|
46
|
+
if name in {"mode", "exploratory_algorithm", "search_scope"}:
|
|
47
|
+
continue
|
|
48
|
+
if name == "max_seconds" and value is None:
|
|
49
|
+
continue
|
|
42
50
|
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
|
43
51
|
raise ValueError(f"{name} must be finite")
|
|
44
52
|
if not isinstance(self.max_bars, int) or not 1 <= self.max_bars <= 64:
|
|
45
53
|
raise ValueError("max_bars must be an integer in 1..64")
|
|
46
|
-
if
|
|
47
|
-
raise ValueError("
|
|
54
|
+
if self.max_seconds is not None and self.max_seconds <= 0:
|
|
55
|
+
raise ValueError("max_seconds must be positive")
|
|
56
|
+
if self.mode not in {"strict", "exploratory"}:
|
|
57
|
+
raise ValueError("mode must be strict or exploratory")
|
|
58
|
+
if self.exploratory_algorithm not in {"waveform", "spectral_context"}:
|
|
59
|
+
raise ValueError("exploratory_algorithm must be waveform or spectral_context")
|
|
60
|
+
if self.search_scope not in {"sections", "whole_song"}:
|
|
61
|
+
raise ValueError("search_scope must be sections or whole_song")
|
|
62
|
+
if self.search_scope == "whole_song" and self.mode != "exploratory":
|
|
63
|
+
raise ValueError("whole_song scope requires exploratory mode")
|
|
64
|
+
max_results = 64 if self.mode == "exploratory" else 8
|
|
65
|
+
if not isinstance(self.loops_per_section, int) or not 1 <= self.loops_per_section <= max_results:
|
|
66
|
+
raise ValueError(f"loops_per_section must be an integer in 1..{max_results}")
|
|
48
67
|
if not 0 <= self.snap_ms <= 20 or not 0 <= self.vocal_guard_ms <= 1000:
|
|
49
68
|
raise ValueError("snap_ms must be in 0..20 and vocal_guard_ms in 0..1000")
|
|
50
69
|
if self.vocal_threshold_dbfs >= 0 or self.vocal_relative_db >= 0:
|
|
@@ -67,7 +86,7 @@ def _kind(label: str) -> str | None:
|
|
|
67
86
|
words = re.findall(r"[a-z]+", label.lower())
|
|
68
87
|
if any(x in words for x in ("pre", "post", "prechorus", "postchorus")):
|
|
69
88
|
return None
|
|
70
|
-
return next((kind for kind in ("verse", "chorus") if kind in words), None)
|
|
89
|
+
return next((kind for kind in ("verse", "chorus", "hook") if kind in words), None)
|
|
71
90
|
|
|
72
91
|
|
|
73
92
|
def _sections(song_map: dict | None, duration: float) -> list[dict]:
|
|
@@ -246,7 +265,7 @@ def grid_metrics(beats: np.ndarray, down_indices: np.ndarray, first: int, last:
|
|
|
246
265
|
|
|
247
266
|
|
|
248
267
|
def optimize_join(audio: np.ndarray, sr: int, start: int, end: int,
|
|
249
|
-
lower: int, upper: int, cfg: LoopConfig) -> dict | None:
|
|
268
|
+
lower: int, upper: int, cfg: LoopConfig, *, allow_failed: bool = False) -> dict | None:
|
|
250
269
|
"""Same offset at both cuts keeps length fixed. Score every channel."""
|
|
251
270
|
radius = round(cfg.snap_ms * sr / 1000)
|
|
252
271
|
lo = max(-radius, lower - start, -start)
|
|
@@ -258,16 +277,56 @@ def optimize_join(audio: np.ndarray, sr: int, start: int, end: int,
|
|
|
258
277
|
jump = np.max(np.abs(audio[a] - audio[b - 1]), axis=1)
|
|
259
278
|
slope = np.max(np.abs((audio[a + 1] - audio[a]) - (audio[b - 1] - audio[b - 2])), axis=1)
|
|
260
279
|
valid = (jump <= cfg.max_join_step) & (slope <= cfg.max_join_slope_error)
|
|
261
|
-
if not valid.any():
|
|
280
|
+
if not valid.any() and not allow_failed:
|
|
262
281
|
return None
|
|
263
282
|
cost = jump / cfg.max_join_step + 0.3 * slope / cfg.max_join_slope_error
|
|
264
283
|
cost += 0.02 * np.abs(offsets) / max(1, radius)
|
|
265
|
-
|
|
284
|
+
if not allow_failed:
|
|
285
|
+
cost[~valid] = np.inf
|
|
266
286
|
best = int(np.argmin(cost))
|
|
267
287
|
return dict(start_sample=int(a[best]), end_sample=int(b[best]),
|
|
268
288
|
common_offset_samples=int(offsets[best]),
|
|
269
289
|
max_channel_step=float(jump[best]),
|
|
270
|
-
max_channel_slope_error=float(slope[best]), cost=float(cost[best])
|
|
290
|
+
max_channel_slope_error=float(slope[best]), cost=float(cost[best]),
|
|
291
|
+
passes_waveform_join=bool(valid[best]))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def spectral_context(audio: np.ndarray, sr: int, start: int, end: int) -> dict:
|
|
295
|
+
"""Compare local spectral envelopes at two cut neighborhoods; not a seam test.
|
|
296
|
+
|
|
297
|
+
This is an inexpensive feature-similarity ranking inspired by recurrence
|
|
298
|
+
methods, not an implementation of a published loop detector.
|
|
299
|
+
"""
|
|
300
|
+
size = max(32, round(0.35 * sr))
|
|
301
|
+
envelopes, chroma_vectors = [], []
|
|
302
|
+
levels = []
|
|
303
|
+
for lo, hi in ((end - size, end), (start, start + size)):
|
|
304
|
+
block = np.zeros((size, audio.shape[1]), dtype=np.float64)
|
|
305
|
+
a, b = max(0, lo), min(len(audio), hi)
|
|
306
|
+
block[a - lo:b - lo] = audio[a:b]
|
|
307
|
+
levels.append(float(np.sqrt(np.mean(block ** 2))))
|
|
308
|
+
spectrum = np.mean(np.abs(np.fft.rfft(block * np.hanning(len(block))[:, None], axis=0)), axis=1)
|
|
309
|
+
frequencies = np.fft.rfftfreq(len(block), 1 / sr)
|
|
310
|
+
edges = np.geomspace(50, min(16000, sr / 2), 25)
|
|
311
|
+
bands = np.array([np.sum(spectrum[(frequencies >= left) & (frequencies < right)])
|
|
312
|
+
for left, right in zip(edges[:-1], edges[1:])])
|
|
313
|
+
envelopes.append(np.log1p(bands))
|
|
314
|
+
usable = (frequencies >= 80) & (frequencies <= min(5000, sr / 2))
|
|
315
|
+
pitch_classes = np.mod(np.rint(69 + 12 * np.log2(frequencies[usable] / 440)).astype(int), 12)
|
|
316
|
+
chroma_vectors.append(np.bincount(pitch_classes, weights=spectrum[usable], minlength=12))
|
|
317
|
+
|
|
318
|
+
def cosine(left: np.ndarray, right: np.ndarray) -> float:
|
|
319
|
+
denominator = np.linalg.norm(left) * np.linalg.norm(right)
|
|
320
|
+
return float(np.dot(left, right) / denominator) if denominator > 0 else 0.0
|
|
321
|
+
|
|
322
|
+
envelope_similarity = cosine(*envelopes)
|
|
323
|
+
chroma_similarity = cosine(*chroma_vectors)
|
|
324
|
+
similarity = 0.5 * (envelope_similarity + chroma_similarity)
|
|
325
|
+
level_difference = float(abs(20 * np.log10(max(levels[0], 1e-8) / max(levels[1], 1e-8))))
|
|
326
|
+
return {"feature_similarity": max(0.0, min(1.0, similarity)),
|
|
327
|
+
"log_band_cosine_similarity": max(0.0, min(1.0, envelope_similarity)),
|
|
328
|
+
"chroma_cosine_similarity": max(0.0, min(1.0, chroma_similarity)),
|
|
329
|
+
"rms_difference_db": level_difference, "window_ms": size * 1000 / sr}
|
|
271
330
|
|
|
272
331
|
|
|
273
332
|
def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
@@ -282,7 +341,9 @@ def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
|
282
341
|
if not len(audio) or not np.isfinite(audio).all():
|
|
283
342
|
raise ValueError("Master must contain finite audio samples")
|
|
284
343
|
duration = len(audio) / sr
|
|
285
|
-
sections = _sections(song_map, duration)
|
|
344
|
+
sections = (_sections(song_map, duration) if cfg.search_scope == "sections" else
|
|
345
|
+
[dict(index=-1, kind="whole", occurrence=1, label="Whole master search",
|
|
346
|
+
start=0.0, end=duration)])
|
|
286
347
|
model, beats, down, grid_errors = _grid(beat_results, duration)
|
|
287
348
|
intervals, timing_sources = _vocal_intervals(whisper_result, lyrics_result, canonical)
|
|
288
349
|
gates, vocal_errors = _gates(stems, duration, cfg, normalization_gains or {})
|
|
@@ -290,60 +351,128 @@ def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
|
290
351
|
guard = cfg.vocal_guard_ms / 1000
|
|
291
352
|
safety_cache: dict[int, bool] = {}
|
|
292
353
|
|
|
354
|
+
def acoustic_clear(t: float, margin: float) -> bool:
|
|
355
|
+
return bool(gates) and all(g.clear(t, margin) for g in gates)
|
|
356
|
+
|
|
357
|
+
def timing_clear(t: float, margin: float) -> bool:
|
|
358
|
+
return not any(a - margin <= t <= b + margin for a, b in intervals)
|
|
359
|
+
|
|
293
360
|
def clear(t: float, margin: float) -> bool:
|
|
294
|
-
return
|
|
295
|
-
a - margin <= t <= b + margin for a, b in intervals)
|
|
361
|
+
return acoustic_clear(t, margin) and timing_clear(t, margin)
|
|
296
362
|
|
|
297
363
|
def boundary_clear(index: int) -> bool:
|
|
298
364
|
if index not in safety_cache:
|
|
299
365
|
safety_cache[index] = clear(float(beats[down[index]]), guard + cfg.snap_ms / 1000)
|
|
300
366
|
return safety_cache[index]
|
|
301
367
|
|
|
302
|
-
loops, unresolved = [], []
|
|
368
|
+
loops, exploratory, unresolved, section_diagnostics = [], [], [], []
|
|
303
369
|
for section in sections:
|
|
304
370
|
reason = None
|
|
305
371
|
rejected: Counter = Counter()
|
|
306
|
-
candidates = []
|
|
372
|
+
candidates, near_misses = [], []
|
|
373
|
+
boundary_counts = None
|
|
307
374
|
if model is None:
|
|
308
375
|
reason = "No usable beat/downbeat grid; bar starts are unknown."
|
|
309
|
-
elif not gates:
|
|
376
|
+
elif not gates and cfg.mode == "strict":
|
|
310
377
|
reason = "No valid full-vocal stem; transcript gaps alone cannot prove silence."
|
|
311
378
|
else:
|
|
312
379
|
lower = max(0, math.ceil(section["start"] * sr - 1e-7))
|
|
313
380
|
upper = min(len(audio), math.floor(section["end"] * sr + 1e-7))
|
|
314
381
|
indices = [i for i, b in enumerate(down) if lower <= round(beats[b] * sr) <= upper]
|
|
382
|
+
margin = guard + cfg.snap_ms / 1000
|
|
383
|
+
boundary_counts = {
|
|
384
|
+
"downbeats_in_section": len(indices),
|
|
385
|
+
"acoustically_clear_downbeats": sum(
|
|
386
|
+
acoustic_clear(float(beats[down[i]]), margin) for i in indices),
|
|
387
|
+
"timing_clear_downbeats": sum(
|
|
388
|
+
timing_clear(float(beats[down[i]]), margin) for i in indices),
|
|
389
|
+
"fully_clear_downbeats": sum(boundary_clear(i) for i in indices),
|
|
390
|
+
}
|
|
391
|
+
max_frames = None if cfg.max_seconds is None else math.floor(cfg.max_seconds * sr)
|
|
315
392
|
for i in indices:
|
|
316
393
|
for j in range(i + 1, min(len(down), i + cfg.max_bars + 1)):
|
|
317
394
|
a, b = round(beats[down[i]] * sr), round(beats[down[j]] * sr)
|
|
318
395
|
if b > upper:
|
|
319
396
|
break
|
|
320
|
-
|
|
397
|
+
# The shared boundary offset preserves duration exactly.
|
|
398
|
+
# Once a later downbeat is too long, all following ones are too.
|
|
399
|
+
if max_frames is not None and b - a > max_frames:
|
|
400
|
+
rejected["max_seconds"] += 1
|
|
401
|
+
break
|
|
402
|
+
vocal_ok = boundary_clear(i) and boundary_clear(j)
|
|
403
|
+
if not vocal_ok and cfg.mode == "strict":
|
|
321
404
|
rejected["vocal_boundary"] += 1
|
|
322
405
|
continue
|
|
323
406
|
metrics = grid_metrics(beats, down, i, j, cfg)
|
|
324
407
|
if metrics is None:
|
|
325
408
|
rejected["unstable_grid_or_meter"] += 1
|
|
326
409
|
continue
|
|
327
|
-
join = optimize_join(audio, sr, a, b, lower, upper, cfg
|
|
410
|
+
join = optimize_join(audio, sr, a, b, lower, upper, cfg,
|
|
411
|
+
allow_failed=cfg.mode == "exploratory")
|
|
328
412
|
if join is None:
|
|
329
413
|
rejected["waveform_join"] += 1
|
|
330
414
|
continue
|
|
331
415
|
s, e = join["start_sample"], join["end_sample"]
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
continue
|
|
416
|
+
snapped_vocal_ok = clear(s / sr, guard) and clear(e / sr, guard)
|
|
417
|
+
passes = vocal_ok and snapped_vocal_ok and join["passes_waveform_join"]
|
|
335
418
|
bars = j - i
|
|
336
|
-
# Rank only candidates which already passed every gate.
|
|
337
419
|
preference = {8: 0, 4: 0.06, 16: 0.12, 2: 0.25, 1: 0.45}.get(bars, 0.20)
|
|
338
420
|
cost = preference + 0.25 * join["cost"]
|
|
339
421
|
cost += metrics["maximum_grid_residual_ms"] / (10 * cfg.max_grid_residual_ms)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
422
|
+
item = dict(start_sample=s, end_sample=e, duration_samples=e - s,
|
|
423
|
+
start_seconds=s / sr, end_seconds=e / sr,
|
|
424
|
+
duration_seconds=(e - s) / sr, bars=bars,
|
|
425
|
+
grid_start_sample=a, grid_end_sample=b,
|
|
426
|
+
grid=metrics, join=join, ranking_cost=cost)
|
|
427
|
+
if passes:
|
|
428
|
+
candidates.append(item)
|
|
429
|
+
elif cfg.mode == "exploratory":
|
|
430
|
+
failed = []
|
|
431
|
+
if not gates:
|
|
432
|
+
failed.append("full_vocal_evidence_missing")
|
|
433
|
+
elif not vocal_ok or not snapped_vocal_ok:
|
|
434
|
+
failed.append("vocal_boundary")
|
|
435
|
+
if not join["passes_waveform_join"]:
|
|
436
|
+
failed.append("waveform_join")
|
|
437
|
+
context = spectral_context(audio, sr, s, e)
|
|
438
|
+
# Feature similarity can suggest an audition, but is
|
|
439
|
+
# not a substitute for vocal, grid or seam clearance.
|
|
440
|
+
if cfg.exploratory_algorithm == "spectral_context":
|
|
441
|
+
rank = (1 - context["feature_similarity"]
|
|
442
|
+
+ min(1.0, context["rms_difference_db"] / 24)
|
|
443
|
+
+ preference + metrics["maximum_grid_residual_ms"] / 200)
|
|
444
|
+
else:
|
|
445
|
+
rank = cost
|
|
446
|
+
near_misses.append({**item, "spectral_context": context,
|
|
447
|
+
"failed_gates": failed,
|
|
448
|
+
"status": "requires_listening_or_edit",
|
|
449
|
+
"exploratory_ranking_cost": rank})
|
|
450
|
+
for gate in failed:
|
|
451
|
+
rejected[gate] += 1
|
|
452
|
+
elif not snapped_vocal_ok:
|
|
453
|
+
rejected["vocal_boundary"] += 1
|
|
345
454
|
if not candidates:
|
|
346
|
-
reason = "No candidate met all complete-bar, vocal-clear, grid and join checks."
|
|
455
|
+
reason = "No candidate met all duration, complete-bar, vocal-clear, grid and join checks."
|
|
456
|
+
section_diagnostics.append({"section_index": section["index"], "label": section["label"],
|
|
457
|
+
"boundary_counts": boundary_counts,
|
|
458
|
+
"rejected_candidates": dict(rejected)})
|
|
459
|
+
near_misses.sort(key=lambda x: (x["exploratory_ranking_cost"], x["start_sample"], x["end_sample"]))
|
|
460
|
+
for rank, item in enumerate(near_misses[:cfg.loops_per_section], 1):
|
|
461
|
+
candidate_id = f"{section['kind']}-{section['occurrence']:02d}-x{rank:02d}"
|
|
462
|
+
record = {"id": candidate_id, "section_index": section["index"],
|
|
463
|
+
"section_label": section["label"], "section_kind": section["kind"],
|
|
464
|
+
**item, "file": None, "vocal_clear": "vocal_boundary" not in item["failed_gates"]
|
|
465
|
+
and "full_vocal_evidence_missing" not in item["failed_gates"]}
|
|
466
|
+
if export_audio:
|
|
467
|
+
name = f"exploratory-audio/{candidate_id}-{item['start_sample']}-{item['end_sample']}.wav"
|
|
468
|
+
path = output_dir / name
|
|
469
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
temporary = path.with_suffix(".tmp")
|
|
471
|
+
sf.write(temporary, audio[item["start_sample"]:item["end_sample"]], sr,
|
|
472
|
+
subtype="FLOAT", format="WAV")
|
|
473
|
+
temporary.replace(path)
|
|
474
|
+
record["file"] = name
|
|
475
|
+
exploratory.append(record)
|
|
347
476
|
if reason:
|
|
348
477
|
unresolved.append({**section, "reason": reason, "rejected_candidates": dict(rejected)})
|
|
349
478
|
continue
|
|
@@ -368,9 +497,13 @@ def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
|
368
497
|
result = dict(schema="stemlab.loops.v1", attribution=attribution(),
|
|
369
498
|
source_name=Path(audio_path).name, source_sha256=sha256_file(audio_path),
|
|
370
499
|
sample_rate=int(sr), source_frames=len(audio), channels=audio.shape[1],
|
|
371
|
-
section_source=(song_map or {}).get("section_source")
|
|
500
|
+
section_source=(song_map or {}).get("section_source") if cfg.search_scope == "sections"
|
|
501
|
+
else "whole_master_search_window",
|
|
372
502
|
grid_source=model, config=asdict(cfg), loop_count=len(loops),
|
|
373
|
-
target_section_count=len(sections), loops=loops,
|
|
503
|
+
target_section_count=len(sections), loops=loops,
|
|
504
|
+
exploratory_count=len(exploratory), exploratory_candidates=exploratory,
|
|
505
|
+
unresolved_sections=unresolved,
|
|
506
|
+
section_diagnostics=section_diagnostics,
|
|
374
507
|
export_audio=bool(export_audio), grid_diagnostics=grid_errors,
|
|
375
508
|
vocal_diagnostics=vocal_errors, vocal_timing_sources=timing_sources,
|
|
376
509
|
vocal_sources=[dict(name=g.path.name, threshold_amplitude=g.threshold) for g in gates],
|
|
@@ -382,6 +515,7 @@ def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
|
382
515
|
"Acoustic silence and seam checks are heuristics, not proof of inaudibility. Audition loops.",
|
|
383
516
|
"Loops may contain vocals inside; only the cut neighborhoods must be vocal-clear.",
|
|
384
517
|
"Native source sample rate and channels are preserved. No resampling or normalization.",
|
|
518
|
+
"Exploratory candidates are near-misses only; spectral context ranks them but does not establish a clean edit.",
|
|
385
519
|
])
|
|
386
520
|
tsv = output_dir / "loops.tsv"
|
|
387
521
|
with tsv.open("w", newline="", encoding="utf-8") as f:
|
|
@@ -389,6 +523,14 @@ def analyze_loops(audio_path: Path, output_dir: Path, *, song_map: dict | None,
|
|
|
389
523
|
writer.writerow(["id", "section", "start_sample", "end_sample_exclusive", "sample_rate", "bars"])
|
|
390
524
|
for loop in loops:
|
|
391
525
|
writer.writerow([loop["id"], loop["section_label"], loop["start_sample"], loop["end_sample"], sr, loop["bars"]])
|
|
526
|
+
with (output_dir / "exploratory.tsv").open("w", newline="", encoding="utf-8") as f:
|
|
527
|
+
writer = csv.writer(f, delimiter="\t")
|
|
528
|
+
writer.writerow(["id", "section", "start_sample", "end_sample_exclusive", "sample_rate",
|
|
529
|
+
"bars", "failed_gates", "ranking_cost"])
|
|
530
|
+
for item in exploratory:
|
|
531
|
+
writer.writerow([item["id"], item["section_label"], item["start_sample"],
|
|
532
|
+
item["end_sample"], sr, item["bars"], ",".join(item["failed_gates"]),
|
|
533
|
+
item["exploratory_ranking_cost"]])
|
|
392
534
|
_write_json(output_dir / "loops.json", result)
|
|
393
535
|
return result
|
|
394
536
|
|
|
@@ -455,11 +597,12 @@ def analyze_existing_loops(root: Path, *, export_audio: bool = False,
|
|
|
455
597
|
|
|
456
598
|
def preview_loop(root: Path, loop_id: str) -> tuple[bytes, str]:
|
|
457
599
|
"""Read-only exact slice; never create files merely to audition a loop."""
|
|
458
|
-
if not re.fullmatch(r"(?:verse|chorus)-\d
|
|
600
|
+
if not re.fullmatch(r"(?:verse|chorus|hook|whole)-\d+-(?:\d+|x\d+)", loop_id):
|
|
459
601
|
raise ValueError("Invalid loop id")
|
|
460
602
|
root = root.resolve()
|
|
461
603
|
report = _read(root / "deep/loops/loops.json")
|
|
462
|
-
item = next((x for x in report.get("loops", [])
|
|
604
|
+
item = next((x for x in report.get("loops", []) + report.get("exploratory_candidates", [])
|
|
605
|
+
if x.get("id") == loop_id), None)
|
|
463
606
|
if item is None:
|
|
464
607
|
raise FileNotFoundError("Loop not found; run stemlab loops on this result directory")
|
|
465
608
|
analysis = _read(root / "analysis.json")
|
|
@@ -14,7 +14,7 @@ from .harmony import analyze_harmony
|
|
|
14
14
|
from .lyrics import analyze_lyrics, transcript_to_lyrics
|
|
15
15
|
from .rhythm import analyze_rhythm
|
|
16
16
|
from .song_map import analyze_song_map
|
|
17
|
-
from .loops import analyze_loops
|
|
17
|
+
from .loops import LoopConfig, analyze_loops
|
|
18
18
|
|
|
19
19
|
ProgressFn = Callable[[str], None]
|
|
20
20
|
|
|
@@ -57,6 +57,7 @@ def run_comprehensive_analysis(
|
|
|
57
57
|
run_basic_pitch: bool = False,
|
|
58
58
|
run_loops: bool = True,
|
|
59
59
|
export_loops: bool = False,
|
|
60
|
+
loop_max_seconds: float | None = None,
|
|
60
61
|
normalization_gains: dict[str, float] | None = None,
|
|
61
62
|
device: str = "auto",
|
|
62
63
|
) -> dict[str, Any]:
|
|
@@ -143,7 +144,7 @@ def run_comprehensive_analysis(
|
|
|
143
144
|
beat_results=beat_results, stems=stems,
|
|
144
145
|
whisper_result=whisper_result, lyrics_result=lyrics_result,
|
|
145
146
|
canonical=canonical, normalization_gains=normalization_gains,
|
|
146
|
-
export_audio=export_loops,
|
|
147
|
+
export_audio=export_loops, config=LoopConfig(max_seconds=loop_max_seconds),
|
|
147
148
|
))
|
|
148
149
|
|
|
149
150
|
if run_audio_semantics:
|
|
@@ -47,6 +47,7 @@ def analyze(
|
|
|
47
47
|
no_deep_analysis: Annotated[bool, typer.Option("--no-deep-analysis", help="Skip derived sonic/harmonic/rhythmic/lyrical analysis")] = False,
|
|
48
48
|
no_loops: Annotated[bool, typer.Option("--no-loops", help="Skip verse/chorus loop discovery")] = False,
|
|
49
49
|
export_loops: Annotated[bool, typer.Option("--export-loops", help="Write accepted native-rate loops as WAVs")] = False,
|
|
50
|
+
max_loop_seconds: Annotated[float | None, typer.Option("--max-loop-seconds", min=0.001, help="Maximum discovered loop duration in seconds")] = None,
|
|
50
51
|
no_structure: Annotated[bool, typer.Option("--no-structure", help="Skip All-In-One functional section analysis")] = False,
|
|
51
52
|
no_text_semantics: Annotated[bool, typer.Option("--no-text-semantics", help="Skip sentence-embedding analysis of lyrics/transcript")] = False,
|
|
52
53
|
audio_semantics: Annotated[bool, typer.Option("--audio-semantics", help="Enable MuQ-MuLan zero-shot music/text similarity (CC-BY-NC model weights)")] = False,
|
|
@@ -87,6 +88,7 @@ def analyze(
|
|
|
87
88
|
run_basic_pitch=basic_pitch,
|
|
88
89
|
run_loops=not no_loops,
|
|
89
90
|
export_loops=export_loops,
|
|
91
|
+
loop_max_seconds=max_loop_seconds,
|
|
90
92
|
)
|
|
91
93
|
console.print(f"[bold]StemLab[/bold] device={device_string(device)} models={', '.join(models)}")
|
|
92
94
|
result = run_pipeline(cfg, progress=lambda m: console.print(f"[cyan]→[/cyan] {m}"))
|
|
@@ -103,19 +105,26 @@ def analyze(
|
|
|
103
105
|
@app.command("loops")
|
|
104
106
|
def find_loops(
|
|
105
107
|
results: Annotated[Path, typer.Argument(exists=True, file_okay=False, help="Existing analysis result directory")],
|
|
106
|
-
export_loops: Annotated[bool, typer.Option("--export-loops", help="
|
|
108
|
+
export_loops: Annotated[bool, typer.Option("--export-loops", help="Write accepted loops and exploratory audition slices as WAVs")] = False,
|
|
107
109
|
max_bars: Annotated[int, typer.Option("--max-bars", min=1, max=64)] = 16,
|
|
108
|
-
|
|
110
|
+
max_seconds: Annotated[float | None, typer.Option("--max-seconds", min=0.001, help="Maximum loop duration in seconds (no limit by default)")] = None,
|
|
111
|
+
per_section: Annotated[int, typer.Option("--per-section", min=1, max=64)] = 1,
|
|
112
|
+
mode: Annotated[str, typer.Option("--mode", help="strict or exploratory; exploratory near-misses are not accepted loops")] = "strict",
|
|
113
|
+
exploratory_algorithm: Annotated[str, typer.Option("--exploratory-algorithm", help="waveform or spectral_context ranking")] = "spectral_context",
|
|
114
|
+
search_scope: Annotated[str, typer.Option("--search-scope", help="sections or whole_song (exploratory only)")] = "sections",
|
|
109
115
|
):
|
|
110
116
|
"""Find loops in saved evidence without rerunning separation or models."""
|
|
111
117
|
from .analysis.loops import LoopConfig, analyze_existing_loops
|
|
112
118
|
|
|
113
119
|
try:
|
|
114
120
|
report = analyze_existing_loops(results, export_audio=export_loops,
|
|
115
|
-
config=LoopConfig(max_bars=max_bars,
|
|
121
|
+
config=LoopConfig(max_bars=max_bars, max_seconds=max_seconds,
|
|
122
|
+
loops_per_section=per_section, mode=mode,
|
|
123
|
+
exploratory_algorithm=exploratory_algorithm, search_scope=search_scope))
|
|
116
124
|
except (ValueError, KeyError, OSError) as exc:
|
|
117
125
|
raise typer.BadParameter(str(exc)) from exc
|
|
118
|
-
console.print(f"{report['loop_count']} loop(s); {
|
|
126
|
+
console.print(f"{report['loop_count']} accepted loop(s); {report['exploratory_count']} audition candidate(s); "
|
|
127
|
+
f"{len(report['unresolved_sections'])} unresolved search window(s).")
|
|
119
128
|
console.print(str(results / "deep/loops/loops.json"))
|
|
120
129
|
|
|
121
130
|
|
|
@@ -110,16 +110,23 @@ class StemLabBridge:
|
|
|
110
110
|
"options": options, "canonical_path": canonical_path})
|
|
111
111
|
|
|
112
112
|
def start_loop_scan(self, results_path: str, *, export_audio: bool = False,
|
|
113
|
-
max_bars: int = 16,
|
|
113
|
+
max_bars: int = 16, max_seconds: float | None = None,
|
|
114
|
+
per_section: int = 1, mode: str = "strict",
|
|
115
|
+
exploratory_algorithm: str = "spectral_context",
|
|
116
|
+
search_scope: str = "sections") -> dict[str, Any]:
|
|
114
117
|
self._writable()
|
|
115
118
|
if type(export_audio) is not bool or type(max_bars) is not int or type(per_section) is not int:
|
|
116
119
|
raise ValueError("Invalid loop option types")
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
from stemlab.analysis.loops import LoopConfig
|
|
121
|
+
LoopConfig(max_bars=max_bars, max_seconds=max_seconds, loops_per_section=per_section,
|
|
122
|
+
mode=mode, exploratory_algorithm=exploratory_algorithm, search_scope=search_scope)
|
|
119
123
|
root = result_root(self.workspace, results_path)
|
|
120
124
|
return self.jobs.submit({"kind": "loops", "results_path": str(root.relative_to(self.workspace)),
|
|
121
125
|
"export_audio": export_audio, "max_bars": max_bars,
|
|
122
|
-
"
|
|
126
|
+
"max_seconds": max_seconds,
|
|
127
|
+
"per_section": per_section, "mode": mode,
|
|
128
|
+
"exploratory_algorithm": exploratory_algorithm,
|
|
129
|
+
"search_scope": search_scope})
|
|
123
130
|
|
|
124
131
|
def list_artifacts(self, results_path: str, *, prefix: str = "", offset: int = 0,
|
|
125
132
|
limit: int = 100) -> dict[str, Any]:
|
|
@@ -106,7 +106,8 @@ def create_server(bridge: StemLabBridge) -> MCPServer:
|
|
|
106
106
|
models: list[str] | None = None, device: str = "auto",
|
|
107
107
|
canonical_path: str | None = None, allow_model_downloads: bool = False,
|
|
108
108
|
allow_expensive: bool = False, allow_external_bootstrap: bool = False,
|
|
109
|
-
export_loops: bool = False,
|
|
109
|
+
export_loops: bool = False, max_loop_seconds: float | None = None,
|
|
110
|
+
run_whisper: bool = True, run_beats: bool = True,
|
|
110
111
|
run_vamp: bool = True, run_structure: bool = True, run_text_semantics: bool = False,
|
|
111
112
|
run_basic_pitch: bool = False, noncommercial_audio_semantics: bool = False,
|
|
112
113
|
) -> dict:
|
|
@@ -120,6 +121,7 @@ def create_server(bridge: StemLabBridge) -> MCPServer:
|
|
|
120
121
|
options = {"profile": profile, "models": models, "device": device,
|
|
121
122
|
"allow_model_downloads": allow_model_downloads, "allow_expensive": allow_expensive,
|
|
122
123
|
"allow_external_bootstrap": allow_external_bootstrap, "export_loops": export_loops,
|
|
124
|
+
"max_loop_seconds": max_loop_seconds,
|
|
123
125
|
"run_whisper": run_whisper, "run_beats": run_beats, "run_vamp": run_vamp,
|
|
124
126
|
"run_structure": run_structure, "run_text_semantics": run_text_semantics,
|
|
125
127
|
"run_basic_pitch": run_basic_pitch,
|
|
@@ -128,14 +130,22 @@ def create_server(bridge: StemLabBridge) -> MCPServer:
|
|
|
128
130
|
|
|
129
131
|
@server.tool(annotations=write)
|
|
130
132
|
def stemlab_start_loop_scan(results_path: str, export_audio: bool = False,
|
|
131
|
-
max_bars: int = 16,
|
|
132
|
-
|
|
133
|
+
max_bars: int = 16, max_seconds: float | None = None,
|
|
134
|
+
per_section: int = 1, mode: str = "strict",
|
|
135
|
+
exploratory_algorithm: str = "spectral_context",
|
|
136
|
+
search_scope: str = "sections") -> dict:
|
|
137
|
+
"""Reuse saved evidence to scan loop cut points in a NEW job.
|
|
133
138
|
|
|
134
139
|
No models rerun, no downloads and no modification of the input results directory.
|
|
135
|
-
Returns a job ID; poll status. max_bars 1..64,
|
|
140
|
+
Returns a job ID; poll status. max_bars 1..64, optional positive
|
|
141
|
+
max_seconds, per_section 1..8 strict or 1..64 exploratory.
|
|
142
|
+
whole_song scope is exploratory only; near-misses are not accepted loops.
|
|
136
143
|
"""
|
|
137
144
|
return bridge.start_loop_scan(results_path, export_audio=export_audio,
|
|
138
|
-
max_bars=max_bars,
|
|
145
|
+
max_bars=max_bars, max_seconds=max_seconds,
|
|
146
|
+
per_section=per_section, mode=mode,
|
|
147
|
+
exploratory_algorithm=exploratory_algorithm,
|
|
148
|
+
search_scope=search_scope)
|
|
139
149
|
|
|
140
150
|
@server.tool(annotations=ToolAnnotations(read_only_hint=False, destructive_hint=True,
|
|
141
151
|
idempotent_hint=True, open_world_hint=False))
|
|
@@ -32,7 +32,7 @@ def analysis_options(options: dict[str, Any]) -> dict[str, Any]:
|
|
|
32
32
|
allowed = {"profile", "models", "device", "allow_model_downloads", "allow_expensive",
|
|
33
33
|
"allow_external_bootstrap", "export_loops", "run_whisper", "run_beats",
|
|
34
34
|
"run_vamp", "run_structure", "run_text_semantics", "run_basic_pitch",
|
|
35
|
-
"noncommercial_audio_semantics"}
|
|
35
|
+
"noncommercial_audio_semantics", "max_loop_seconds"}
|
|
36
36
|
if set(options) - allowed:
|
|
37
37
|
raise ValueError("Unknown analysis option")
|
|
38
38
|
selected = options.get("profile", "practical")
|
|
@@ -51,7 +51,9 @@ def analysis_options(options: dict[str, Any]) -> dict[str, Any]:
|
|
|
51
51
|
import re
|
|
52
52
|
if not isinstance(device, str) or not re.fullmatch(r"auto|cpu|mps|cuda(?::\d+)?", device):
|
|
53
53
|
raise ValueError("Invalid device")
|
|
54
|
-
|
|
54
|
+
from stemlab.analysis.loops import LoopConfig
|
|
55
|
+
LoopConfig(max_seconds=options.get("max_loop_seconds"))
|
|
56
|
+
booleans = allowed - {"models", "profile", "device", "max_loop_seconds"}
|
|
55
57
|
if any(k in options and type(options[k]) is not bool for k in booleans):
|
|
56
58
|
raise ValueError("Boolean options must be true or false")
|
|
57
59
|
return {**options, "profile": selected, "models": list(dict.fromkeys(models)), "device": device}
|
|
@@ -74,6 +76,7 @@ def run_analysis(workspace: Path, output: Path, task: dict[str, Any]) -> dict[st
|
|
|
74
76
|
input_wav=source, output_dir=output, models=tuple(options["models"]),
|
|
75
77
|
device=options["device"], bootstrap_external=options.get("allow_external_bootstrap", False),
|
|
76
78
|
export_loops=options.get("export_loops", False),
|
|
79
|
+
loop_max_seconds=options.get("max_loop_seconds"),
|
|
77
80
|
run_whisper=options.get("run_whisper", True), run_beats=options.get("run_beats", True),
|
|
78
81
|
run_vamp=options.get("run_vamp", True), run_structure=options.get("run_structure", True),
|
|
79
82
|
run_text_semantics=options.get("run_text_semantics", False),
|
|
@@ -131,7 +134,11 @@ def run_loop_scan(workspace: Path, output: Path, task: dict[str, Any]) -> dict[s
|
|
|
131
134
|
copied = output / "input" / master.name
|
|
132
135
|
shutil.copyfile(master, copied)
|
|
133
136
|
config = LoopConfig(max_bars=task.get("max_bars", 16),
|
|
134
|
-
|
|
137
|
+
max_seconds=task.get("max_seconds"),
|
|
138
|
+
loops_per_section=task.get("per_section", 1),
|
|
139
|
+
mode=task.get("mode", "strict"),
|
|
140
|
+
exploratory_algorithm=task.get("exploratory_algorithm", "spectral_context"),
|
|
141
|
+
search_scope=task.get("search_scope", "sections"))
|
|
135
142
|
report = analyze_loops(copied, output / "deep/loops", song_map=song_map, beat_results=beats,
|
|
136
143
|
stems=stems, canonical=canonical, normalization_gains=gains,
|
|
137
144
|
whisper_result=load("speech/whisper.json"),
|
|
@@ -48,6 +48,8 @@ def _error_record(stage: str, exc: Exception) -> dict:
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def run_pipeline(config: PipelineConfig, progress: ProgressFn | None = None) -> dict:
|
|
51
|
+
from .analysis.loops import LoopConfig
|
|
52
|
+
LoopConfig(max_seconds=config.loop_max_seconds)
|
|
51
53
|
if config.export_loops and (not config.run_loops or not config.run_deep_analysis):
|
|
52
54
|
raise ValueError("Loop export requires loop discovery and deep analysis")
|
|
53
55
|
progress = progress or (lambda _msg: None)
|
|
@@ -357,6 +359,7 @@ def run_pipeline(config: PipelineConfig, progress: ProgressFn | None = None) ->
|
|
|
357
359
|
run_basic_pitch=config.run_basic_pitch,
|
|
358
360
|
run_loops=config.run_loops,
|
|
359
361
|
export_loops=config.export_loops,
|
|
362
|
+
loop_max_seconds=config.loop_max_seconds,
|
|
360
363
|
normalization_gains={
|
|
361
364
|
str(out / item["path"]): float(item.get("gain_db", 0.0))
|
|
362
365
|
for model in analysis["models"] for item in model.get("normalization", [])
|