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.
Files changed (123) hide show
  1. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/PKG-INFO +9 -1
  2. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/README.md +8 -0
  3. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/PKG-INFO +9 -1
  4. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/__init__.py +1 -1
  5. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/loops.py +172 -29
  6. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/runner.py +3 -2
  7. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/cli.py +13 -4
  8. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/bridge.py +11 -4
  9. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/server.py +15 -5
  10. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/tasks.py +10 -3
  11. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/pipeline.py +3 -0
  12. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/types.py +1 -0
  13. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_bridge.py +40 -2
  14. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_distribution_identity.py +1 -1
  15. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_loop_integration.py +7 -2
  16. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_loops.py +63 -1
  17. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.agents/plugins/marketplace.json +0 -0
  18. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.github/workflows/codex-plugin.yml +0 -0
  19. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/.github/workflows/release.yml +0 -0
  20. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/LICENSE +0 -0
  21. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/MANIFEST.in +0 -0
  22. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/docs/codex-plugin.md +0 -0
  23. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/docs/codex-publishing.md +0 -0
  24. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/mcp-registry/server.json +0 -0
  25. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/.codex-plugin/plugin.json +0 -0
  26. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/.mcp.json +0 -0
  27. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/README.md +0 -0
  28. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/mcp.json +0 -0
  29. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/plugin.json +0 -0
  30. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/skills/stemlab/SKILL.md +0 -0
  31. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/plugins/stemlab/skills/stemlab/references/tools.md +0 -0
  32. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/pyproject.toml +0 -0
  33. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/build_codex_plugin.py +0 -0
  34. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/capture_loop_workflow.py +0 -0
  35. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/check_codex_timeline.py +0 -0
  36. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/loop_demo.py +0 -0
  37. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/prepare_codex_marketplace.py +0 -0
  38. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/scripts/publish_mcp_registry.py +0 -0
  39. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/setup.cfg +0 -0
  40. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/SOURCES.txt +0 -0
  41. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/dependency_links.txt +0 -0
  42. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/entry_points.txt +0 -0
  43. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/requires.txt +0 -0
  44. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/danceflow_stemlab.egg-info/top_level.txt +0 -0
  45. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/__main__.py +0 -0
  46. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/__init__.py +0 -0
  47. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/harmony.py +0 -0
  48. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/lyrics.py +0 -0
  49. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/registry.py +0 -0
  50. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/rhythm.py +0 -0
  51. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/semantic_audio.py +0 -0
  52. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/semantic_text.py +0 -0
  53. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/song_map.py +0 -0
  54. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/sonic.py +0 -0
  55. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/structure.py +0 -0
  56. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/tempo_regimes.py +0 -0
  57. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/analysis/transcription.py +0 -0
  58. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/audio.py +0 -0
  59. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/__init__.py +0 -0
  60. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/base.py +0 -0
  61. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beatnet.py +0 -0
  62. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beatthis.py +0 -0
  63. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/beattransformer.py +0 -0
  64. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/beats/common.py +0 -0
  65. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/bootstrap.py +0 -0
  66. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/branding.py +0 -0
  67. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/canonical.py +0 -0
  68. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/__init__.py +0 -0
  69. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/__main__.py +0 -0
  70. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/jobs.py +0 -0
  71. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/paths.py +0 -0
  72. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/timeline.py +0 -0
  73. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/codex/worker.py +0 -0
  74. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/manifest.py +0 -0
  75. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/models.py +0 -0
  76. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/__init__.py +0 -0
  77. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/base.py +0 -0
  78. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/bsroformer.py +0 -0
  79. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/common.py +0 -0
  80. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/demucs.py +0 -0
  81. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/openunmix.py +0 -0
  82. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/separators/scnet.py +0 -0
  83. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/server.py +0 -0
  84. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/sonic_visualiser.py +0 -0
  85. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/spectrogram.py +0 -0
  86. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/speech.py +0 -0
  87. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/util.py +0 -0
  88. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/vamp.py +0 -0
  89. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/vamp_runtime.py +0 -0
  90. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/app.js +0 -0
  91. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/arcadians-cover.jpg +0 -0
  92. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/arcadians-reference.json +0 -0
  93. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/index.html +0 -0
  94. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/style.css +0 -0
  95. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/web/timeline.css +0 -0
  96. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/src/stemlab/webui.py +0 -0
  97. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_analysis_registry.py +0 -0
  98. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_arcadians_fixture.py +0 -0
  99. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_audio_normalization.py +0 -0
  100. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_beat_consensus.py +0 -0
  101. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_beatnet_compat.py +0 -0
  102. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_branding.py +0 -0
  103. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_canonical.py +0 -0
  104. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_canonical_extended.py +0 -0
  105. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_jobs.py +0 -0
  106. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_package.py +0 -0
  107. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_protocol.py +0 -0
  108. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_release.py +0 -0
  109. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_codex_timeline.py +0 -0
  110. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_harmony.py +0 -0
  111. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_lyrics.py +0 -0
  112. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_deep_sonic_rhythm.py +0 -0
  113. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_manifest.py +0 -0
  114. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_models.py +0 -0
  115. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_repository_hygiene.py +0 -0
  116. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_server.py +0 -0
  117. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_song_map.py +0 -0
  118. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_sonic_visualiser.py +0 -0
  119. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_sonic_visualiser_workspace.py +0 -0
  120. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_speech_repetition.py +0 -0
  121. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_vamp.py +0 -0
  122. {danceflow_stemlab-1.1.2 → danceflow_stemlab-1.2.0}/tests/test_vamp_runtime.py +0 -0
  123. {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.1.2
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.1.2
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
@@ -7,4 +7,4 @@ Created by Kieran Simkin — https://kieransimkin.co.uk/
7
7
  Music catalogue — https://kieransimkin.co.uk/my-songs/
8
8
  """
9
9
 
10
- __version__ = "1.1.2"
10
+ __version__ = "1.2.0"
@@ -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 not isinstance(self.loops_per_section, int) or not 1 <= self.loops_per_section <= 8:
47
- raise ValueError("loops_per_section must be an integer in 1..8")
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
- cost[~valid] = np.inf
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 bool(gates) and all(g.clear(t, margin) for g in gates) and not any(
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
- if not boundary_clear(i) or not boundary_clear(j):
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
- if not clear(s / sr, guard) or not clear(e / sr, guard):
333
- rejected["vocal_boundary"] += 1
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
- candidates.append(dict(start_sample=s, end_sample=e, duration_samples=e - s,
341
- start_seconds=s / sr, end_seconds=e / sr,
342
- duration_seconds=(e - s) / sr, bars=bars,
343
- grid_start_sample=a, grid_end_sample=b,
344
- grid=metrics, join=join, ranking_cost=cost))
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, unresolved_sections=unresolved,
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+-\d+", loop_id):
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", []) if x.get("id") == loop_id), None)
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="Also write accepted loops as WAVs")] = False,
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
- per_section: Annotated[int, typer.Option("--per-section", min=1, max=8)] = 1,
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, loops_per_section=per_section))
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); {len(report['unresolved_sections'])} unresolved section(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, per_section: int = 1) -> dict[str, Any]:
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
- if not 1 <= max_bars <= 64 or not 1 <= per_section <= 8:
118
- raise ValueError("max_bars must be 1..64; per_section 1..8")
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
- "per_section": per_section})
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, run_whisper: bool = True, run_beats: bool = True,
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, per_section: int = 1) -> dict:
132
- """Reuse saved evidence, scan verse/chorus loops, optionally export WAVs to a NEW job.
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, per_section 1..8.
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, per_section=per_section)
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
- booleans = allowed - {"models", "profile", "device"}
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
- loops_per_section=task.get("per_section", 1))
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", [])
@@ -72,3 +72,4 @@ class PipelineConfig:
72
72
  run_basic_pitch: bool = False
73
73
  run_loops: bool = True
74
74
  export_loops: bool = False
75
+ loop_max_seconds: float | None = None