danceflow-stemlab 1.4.0__tar.gz → 1.4.2__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 (176) hide show
  1. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/AGENTS.md +6 -0
  2. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/PKG-INFO +16 -1
  3. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/README.md +15 -0
  4. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/mcp-registry/server.json +2 -2
  5. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/.codex-plugin/plugin.json +1 -1
  6. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/README.md +6 -0
  7. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/plugin.json +1 -1
  8. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/SKILL.md +11 -0
  9. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/PKG-INFO +16 -1
  10. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/SOURCES.txt +4 -0
  11. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/__init__.py +1 -1
  12. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/loops.py +32 -0
  13. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/phrase_loops.py +19 -3
  14. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cli.py +6 -1
  15. danceflow_stemlab-1.4.2/src/stemlab/clip_cli.py +34 -0
  16. danceflow_stemlab-1.4.2/src/stemlab/clip_match.py +190 -0
  17. danceflow_stemlab-1.4.2/tests/test_bar_phase.py +74 -0
  18. danceflow_stemlab-1.4.2/tests/test_clip_match.py +103 -0
  19. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_phrase_loops.py +3 -1
  20. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.agents/plugins/marketplace.json +0 -0
  21. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.github/workflows/codex-plugin.yml +0 -0
  22. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.github/workflows/release.yml +0 -0
  23. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/CONTRIBUTING.md +0 -0
  24. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/LICENSE +0 -0
  25. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/MANIFEST.in +0 -0
  26. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/README.md +0 -0
  27. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo-monochrome.svg +0 -0
  28. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo.png +0 -0
  29. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo.svg +0 -0
  30. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/codex-plugin.md +0 -0
  31. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/codex-publishing.md +0 -0
  32. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/cue-alignment.md +0 -0
  33. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/evidence-models.md +0 -0
  34. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/midi.md +0 -0
  35. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/.mcp.json +0 -0
  36. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo-monochrome.svg +0 -0
  37. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.png +0 -0
  38. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.svg +0 -0
  39. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/mcp.json +0 -0
  40. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/references/tools.md +0 -0
  41. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/pyproject.toml +0 -0
  42. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/benchmark_evidence_corpus.py +0 -0
  43. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/build_codex_plugin.py +0 -0
  44. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/capture_loop_workflow.py +0 -0
  45. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/check_codex_timeline.py +0 -0
  46. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/loop_demo.py +0 -0
  47. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/prepare_codex_marketplace.py +0 -0
  48. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/publish_mcp_registry.py +0 -0
  49. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/smoke_midi.py +0 -0
  50. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/setup.cfg +0 -0
  51. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/dependency_links.txt +0 -0
  52. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/entry_points.txt +0 -0
  53. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/requires.txt +0 -0
  54. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/top_level.txt +0 -0
  55. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/__main__.py +0 -0
  56. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/__init__.py +0 -0
  57. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/__init__.py +0 -0
  58. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/adapters.py +0 -0
  59. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/registry.py +0 -0
  60. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/runner.py +0 -0
  61. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/worker.py +0 -0
  62. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/harmony.py +0 -0
  63. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/lyrics.py +0 -0
  64. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/__init__.py +0 -0
  65. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/adapters.py +0 -0
  66. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/artifacts.py +0 -0
  67. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/registry.py +0 -0
  68. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/runner.py +0 -0
  69. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/worker.py +0 -0
  70. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/registry.py +0 -0
  71. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rhythm.py +0 -0
  72. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rudiment_matches.py +0 -0
  73. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/runner.py +0 -0
  74. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_audio.py +0 -0
  75. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_text.py +0 -0
  76. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/song_map.py +0 -0
  77. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/sonic.py +0 -0
  78. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/structure.py +0 -0
  79. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/tempo_regimes.py +0 -0
  80. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/transcription.py +0 -0
  81. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/audio.py +0 -0
  82. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/__init__.py +0 -0
  83. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/base.py +0 -0
  84. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatnet.py +0 -0
  85. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatthis.py +0 -0
  86. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beattransformer.py +0 -0
  87. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/common.py +0 -0
  88. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/bootstrap.py +0 -0
  89. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/branding.py +0 -0
  90. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/canonical.py +0 -0
  91. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__init__.py +0 -0
  92. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__main__.py +0 -0
  93. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/bridge.py +0 -0
  94. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/jobs.py +0 -0
  95. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/paths.py +0 -0
  96. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/server.py +0 -0
  97. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/tasks.py +0 -0
  98. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/timeline.py +0 -0
  99. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/worker.py +0 -0
  100. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cue_cli.py +0 -0
  101. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cues.py +0 -0
  102. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/lyric_lrc.py +0 -0
  103. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/manifest.py +0 -0
  104. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/models.py +0 -0
  105. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/pipeline.py +0 -0
  106. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/__init__.py +0 -0
  107. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/base.py +0 -0
  108. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/bsroformer.py +0 -0
  109. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/common.py +0 -0
  110. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/demucs.py +0 -0
  111. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/openunmix.py +0 -0
  112. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/scnet.py +0 -0
  113. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/server.py +0 -0
  114. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/sonic_visualiser.py +0 -0
  115. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram.py +0 -0
  116. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram_repair.py +0 -0
  117. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/speech.py +0 -0
  118. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/types.py +0 -0
  119. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/util.py +0 -0
  120. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/vamp.py +0 -0
  121. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/vamp_runtime.py +0 -0
  122. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/app.js +0 -0
  123. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-cover.jpg +0 -0
  124. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-reference.json +0 -0
  125. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/index.html +0 -0
  126. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/style.css +0 -0
  127. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/timeline.css +0 -0
  128. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/webui.py +0 -0
  129. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/word_alignment.py +0 -0
  130. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_analysis_registry.py +0 -0
  131. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_arcadians_fixture.py +0 -0
  132. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_arcadians_rudiments.py +0 -0
  133. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_audio_normalization.py +0 -0
  134. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beat_consensus.py +0 -0
  135. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beat_this_bootstrap.py +0 -0
  136. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beatnet_bootstrap.py +0 -0
  137. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beatnet_compat.py +0 -0
  138. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_branding.py +0 -0
  139. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_bsroformer_adapter.py +0 -0
  140. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_canonical.py +0 -0
  141. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_canonical_extended.py +0 -0
  142. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_bridge.py +0 -0
  143. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_jobs.py +0 -0
  144. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_package.py +0 -0
  145. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_protocol.py +0 -0
  146. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_release.py +0 -0
  147. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_timeline.py +0 -0
  148. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_cue_integration.py +0 -0
  149. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_cues.py +0 -0
  150. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_harmony.py +0 -0
  151. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_lyrics.py +0 -0
  152. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_sonic_rhythm.py +0 -0
  153. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_distribution_identity.py +0 -0
  154. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_docker_contract.py +0 -0
  155. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_evidence_models.py +0 -0
  156. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_loop_integration.py +0 -0
  157. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_loops.py +0 -0
  158. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_lyric_lrc.py +0 -0
  159. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_manifest.py +0 -0
  160. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_adapters.py +0 -0
  161. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_extraction.py +0 -0
  162. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_integration.py +0 -0
  163. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_registry.py +0 -0
  164. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_models.py +0 -0
  165. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_repository_hygiene.py +0 -0
  166. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_rudiment_matches.py +0 -0
  167. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_server.py +0 -0
  168. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_song_map.py +0 -0
  169. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser.py +0 -0
  170. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser_workspace.py +0 -0
  171. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_spectrogram.py +0 -0
  172. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_speech_repetition.py +0 -0
  173. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_vamp.py +0 -0
  174. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_vamp_runtime.py +0 -0
  175. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_web_frontend.py +0 -0
  176. {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_word_alignment.py +0 -0
@@ -7,3 +7,9 @@
7
7
  - Keep credentials, account data, private media and rights records out of source, fixtures, logs and packages. Use synthetic fixtures or explicitly authorised public examples and preserve their provenance/licences.
8
8
  - Use [CONTRIBUTING.md](CONTRIBUTING.md) for review. Maintainers publish accepted, validated improvements as a fresh matching GitHub release and verify configured registry destinations. Preserve existing tags and release assets; publication and deployment remain subject to applicable authorisation.
9
9
  - Website: https://kieransimkin.co.uk/danceflow/
10
+
11
+ ## Keep README documentation current
12
+
13
+ - Whenever a feature is added or changed, update the relevant README.md in the same change to reflect the resulting behaviour. This applies to every feature addition or modification, including APIs, commands, configuration, defaults, integrations and removals.
14
+ - Update affected feature descriptions, usage instructions, options, examples and limitations. For tools or packages with their own README.md, update that README as well as the repository README when its overview or usage is affected.
15
+ - Check the README against the implemented and verified behaviour before committing, opening or merging a PR, publishing a release or handing off the work. A feature change is incomplete until its README documentation is current; a changelog or other documentation does not replace this requirement.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: danceflow-stemlab
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Local music analysis for stems, timing, beats, harmony, lyrics, MIDI and loopable sections for Shorts. https://kieransimkin.co.uk/
5
5
  Author: Kieran Simkin
6
6
  Maintainer: Kieran Simkin
@@ -425,9 +425,24 @@ This opt-in search compares the musical context around both cuts, preserves
425
425
  canonical lyric occurrences, uses explicit artist feedback and selects diverse
426
426
  passages. Suggestions remain separate from strict accepted loops. See
427
427
  [phrase-loop methodology, feedback format and synthetic example](docs/phrase-loops.md).
428
+ For longer excerpts, add `--min-seconds 20 --max-seconds 30`; optional
429
+ `--max-bar-phase-error 0.125` also screens out mismatched measured bar positions.
430
+ These filters suggest auditions and do not establish seamless or listening approval.
428
431
 
429
432
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
430
433
 
434
+ ## Recover a Short or video's source timing
435
+
436
+ `stemlab match-clip master.wav published-original.mp4 --output new-match.json`
437
+ locates a contiguous soundtrack excerpt without running models or downloading
438
+ anything. It retains both original hashes, timing precision, competing source
439
+ positions and independent window checks. Matched reports provide source offset,
440
+ soundtrack duration and player start for synchronised consumers. Short fades and
441
+ compression are tolerated; ambiguous repetition, edit jumps, different recordings
442
+ and rate changes remain unresolved. Use `--clip-start` / `--clip-end` for an
443
+ explicit known soundtrack interval. FFmpeg is required for video decoding.
444
+ See [clip matching](docs/clip-matching.md) for limitations and a synthetic example.
445
+
431
446
  ## Audio-to-MIDI model comparison
432
447
 
433
448
  MIDI extraction is an **optional, independent model pass**. It adds Transkun V2
@@ -298,9 +298,24 @@ This opt-in search compares the musical context around both cuts, preserves
298
298
  canonical lyric occurrences, uses explicit artist feedback and selects diverse
299
299
  passages. Suggestions remain separate from strict accepted loops. See
300
300
  [phrase-loop methodology, feedback format and synthetic example](docs/phrase-loops.md).
301
+ For longer excerpts, add `--min-seconds 20 --max-seconds 30`; optional
302
+ `--max-bar-phase-error 0.125` also screens out mismatched measured bar positions.
303
+ These filters suggest auditions and do not establish seamless or listening approval.
301
304
 
302
305
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
303
306
 
307
+ ## Recover a Short or video's source timing
308
+
309
+ `stemlab match-clip master.wav published-original.mp4 --output new-match.json`
310
+ locates a contiguous soundtrack excerpt without running models or downloading
311
+ anything. It retains both original hashes, timing precision, competing source
312
+ positions and independent window checks. Matched reports provide source offset,
313
+ soundtrack duration and player start for synchronised consumers. Short fades and
314
+ compression are tolerated; ambiguous repetition, edit jumps, different recordings
315
+ and rate changes remain unresolved. Use `--clip-start` / `--clip-end` for an
316
+ explicit known soundtrack interval. FFmpeg is required for video decoding.
317
+ See [clip matching](docs/clip-matching.md) for limitations and a synthetic example.
318
+
304
319
  ## Audio-to-MIDI model comparison
305
320
 
306
321
  MIDI extraction is an **optional, independent model pass**. It adds Transkun V2
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.kieransimkin/stemlab",
4
4
  "title": "StemLab \u2014 timing and loops for Shorts",
5
5
  "description": "Music and stem analysis, timing and loopable Shorts. https://kieransimkin.co.uk/",
6
- "version": "1.4.0",
6
+ "version": "1.4.1",
7
7
  "websiteUrl": "https://kieransimkin.co.uk/my-songs/",
8
8
  "repository": {
9
9
  "url": "https://github.com/kieransimkin/stemlab",
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "registryType": "pypi",
15
15
  "identifier": "danceflow-stemlab",
16
- "version": "1.4.0",
16
+ "version": "1.4.1",
17
17
  "runtimeHint": "uvx",
18
18
  "runtimeArguments": [
19
19
  {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stemlab",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Local music analysis for stems, timing, beats, harmony, lyrics, MIDI and loopable sections for Shorts. https://kieransimkin.co.uk/",
5
5
  "skills": "./skills/",
6
6
  "mcpServers": "./.mcp.json",
@@ -30,6 +30,12 @@ optional separation/speech/beat backends must already be installed for new infer
30
30
  No model is downloaded at plugin installation. The existing `all` extra does not
31
31
  include the opt-in MuQ or Basic Pitch routes.
32
32
 
33
+ The installed runtime also includes the model-free CLI
34
+ `stemlab match-clip SOURCE CLIP --output NEW.json` for hash-bound Short/video
35
+ soundtrack timing recovery. It runs locally and does not add a separate MCP tool
36
+ or change the timeline. Review competing matches and unresolved states before
37
+ using a result; see the repository's `docs/clip-matching.md`.
38
+
33
39
  `configure` records only a workspace and resource limits in the platform's
34
40
  user-configuration directory (`stemlab/codex.json`). It does not change Codex
35
41
  settings or grant approvals. Use `--replace` only after reviewing an existing file.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "stemlab",
4
- "version": "1.4.0",
4
+ "version": "1.4.2",
5
5
  "description": "Local music analysis for stems, timing, beats, harmony, lyrics, MIDI and loopable sections for Shorts. https://kieransimkin.co.uk/",
6
6
  "author": {
7
7
  "name": "Kieran Simkin",
@@ -42,6 +42,8 @@ Created by Kieran Simkin — https://kieransimkin.co.uk/my-songs/
42
42
  10. For complete lyric-line alternatives and musical restarts, the local CLI
43
43
  `stemlab phrase-loops RESULTS --feedback RATINGS.json --export-audio` reuses
44
44
  explicit canonical timings and saved evidence. See `docs/phrase-loops.md`.
45
+ Use caller-selected duration bounds and optional `--max-bar-phase-error` to
46
+ screen measured bar-position mismatches; preserve unknown context and listening holds.
45
47
  Its separate report contains suggestions, never strict accepted loops or
46
48
  listening approval. Artist ratings are source-hash-bound; keep private
47
49
  ratings and song material out of public examples and packages.
@@ -139,4 +141,13 @@ readiness, and these outputs never rewrite canonical lyrics, sections or beat gr
139
141
 
140
142
  ## Improve StemLab
141
143
 
144
+ For existing video/Short soundtrack provenance, use the local CLI
145
+ `stemlab match-clip SOURCE CLIP --output NEW.json` before running learned models.
146
+ It preserves stereo phase, source/clip hashes and independent timing windows.
147
+ `--clip-start` / `--clip-end` select a known soundtrack interval. A matched report
148
+ provides offset, duration and player start; ambiguous repetition, edits or rate
149
+ changes remain unresolved. It does not grant track identity, artist approval or
150
+ publication rights. See `docs/clip-matching.md`; this CLI is separate from the
151
+ plugin's existing timeline and asynchronous analysis tools.
152
+
142
153
  Inspect and use the available StemLab capabilities, and look for useful improvements wherever possible. Submit improvements with relevant tests, documentation and a reproducible example as PRs to https://github.com/kieransimkin/stemlab/pulls. Follow the repository's AGENTS.md and CONTRIBUTING.md.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: danceflow-stemlab
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Local music analysis for stems, timing, beats, harmony, lyrics, MIDI and loopable sections for Shorts. https://kieransimkin.co.uk/
5
5
  Author: Kieran Simkin
6
6
  Maintainer: Kieran Simkin
@@ -425,9 +425,24 @@ This opt-in search compares the musical context around both cuts, preserves
425
425
  canonical lyric occurrences, uses explicit artist feedback and selects diverse
426
426
  passages. Suggestions remain separate from strict accepted loops. See
427
427
  [phrase-loop methodology, feedback format and synthetic example](docs/phrase-loops.md).
428
+ For longer excerpts, add `--min-seconds 20 --max-seconds 30`; optional
429
+ `--max-bar-phase-error 0.125` also screens out mismatched measured bar positions.
430
+ These filters suggest auditions and do not establish seamless or listening approval.
428
431
 
429
432
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
430
433
 
434
+ ## Recover a Short or video's source timing
435
+
436
+ `stemlab match-clip master.wav published-original.mp4 --output new-match.json`
437
+ locates a contiguous soundtrack excerpt without running models or downloading
438
+ anything. It retains both original hashes, timing precision, competing source
439
+ positions and independent window checks. Matched reports provide source offset,
440
+ soundtrack duration and player start for synchronised consumers. Short fades and
441
+ compression are tolerated; ambiguous repetition, edit jumps, different recordings
442
+ and rate changes remain unresolved. Use `--clip-start` / `--clip-end` for an
443
+ explicit known soundtrack interval. FFmpeg is required for video decoding.
444
+ See [clip matching](docs/clip-matching.md) for limitations and a synthetic example.
445
+
431
446
  ## Audio-to-MIDI model comparison
432
447
 
433
448
  MIDI extraction is an **optional, independent model pass**. It adds Transkun V2
@@ -48,6 +48,8 @@ src/stemlab/bootstrap.py
48
48
  src/stemlab/branding.py
49
49
  src/stemlab/canonical.py
50
50
  src/stemlab/cli.py
51
+ src/stemlab/clip_cli.py
52
+ src/stemlab/clip_match.py
51
53
  src/stemlab/cue_cli.py
52
54
  src/stemlab/cues.py
53
55
  src/stemlab/lyric_lrc.py
@@ -124,6 +126,7 @@ tests/test_analysis_registry.py
124
126
  tests/test_arcadians_fixture.py
125
127
  tests/test_arcadians_rudiments.py
126
128
  tests/test_audio_normalization.py
129
+ tests/test_bar_phase.py
127
130
  tests/test_beat_consensus.py
128
131
  tests/test_beat_this_bootstrap.py
129
132
  tests/test_beatnet_bootstrap.py
@@ -132,6 +135,7 @@ tests/test_branding.py
132
135
  tests/test_bsroformer_adapter.py
133
136
  tests/test_canonical.py
134
137
  tests/test_canonical_extended.py
138
+ tests/test_clip_match.py
135
139
  tests/test_codex_bridge.py
136
140
  tests/test_codex_jobs.py
137
141
  tests/test_codex_package.py
@@ -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.4.0"
10
+ __version__ = "1.4.2"
@@ -264,6 +264,38 @@ def grid_metrics(beats: np.ndarray, down_indices: np.ndarray, first: int, last:
264
264
  last_interval_seconds=float(intervals[-1]))
265
265
 
266
266
 
267
+ def detected_bar_phase_metrics(beats: np.ndarray, down_indices: np.ndarray,
268
+ start_seconds: float, end_seconds: float) -> dict:
269
+ """Compare cut positions on one measured bar grid without assuming 4/4."""
270
+ try:
271
+ beats = _ordered_times(beats)
272
+ indices = np.asarray(down_indices)
273
+ if (indices.ndim != 1 or len(indices) < 2 or indices.dtype.kind not in 'iu'
274
+ or np.any(indices < 0) or np.any(indices >= len(beats))
275
+ or np.any(indices[1:] <= indices[:-1])):
276
+ raise ValueError('Need ordered, distinct measured downbeat indices')
277
+ if (not np.isfinite([start_seconds, end_seconds]).all()
278
+ or not 0 <= start_seconds < end_seconds):
279
+ raise ValueError('Need finite ordered cut times')
280
+ downbeats = beats[indices]
281
+ if start_seconds < downbeats[0] or end_seconds > downbeats[-1]:
282
+ raise ValueError('Cuts lack enclosing measured downbeats')
283
+ first = max(0, int(np.searchsorted(downbeats, start_seconds, side='right'))-1)
284
+ last = max(0, int(np.searchsorted(downbeats, end_seconds, side='left'))-1)
285
+ counts = np.diff(indices)[first:last+1]
286
+ if not len(counts) or np.any(counts != counts[0]) or not 2 <= counts[0] <= 12:
287
+ raise ValueError('Detected beats per bar are unsupported or change across the cut')
288
+ coordinates = np.interp([start_seconds, end_seconds], downbeats,
289
+ np.arange(len(downbeats)))
290
+ span = float(coordinates[1]-coordinates[0])
291
+ return dict(available=True, detected_beats_per_bar=int(counts[0]),
292
+ fractional_bar_count=span, bar_phase_error=abs(span-round(span)),
293
+ start_bar_coordinate=float(coordinates[0]), end_bar_coordinate=float(coordinates[1]),
294
+ interpretation='Measured relative bar position; not artist-confirmed meter or loop approval')
295
+ except (ValueError, TypeError, IndexError) as exc:
296
+ return dict(available=False, reason=str(exc))
297
+
298
+
267
299
  def optimize_join(audio: np.ndarray, sr: int, start: int, end: int,
268
300
  lower: int, upper: int, cfg: LoopConfig, *, allow_failed: bool = False) -> dict | None:
269
301
  """Same offset at both cuts keeps length fixed. Score every channel."""
@@ -15,7 +15,8 @@ import soundfile as sf
15
15
 
16
16
  from stemlab.util import sha256_file
17
17
  from .loops import (LoopConfig, _gates, _grid, _vocal_intervals, _write_json,
18
- index_loop_report, load_loop_evidence, optimize_join, spectral_context)
18
+ detected_bar_phase_metrics, index_loop_report, load_loop_evidence,
19
+ optimize_join, spectral_context)
19
20
  from .rudiment_matches import extract_peak_events
20
21
 
21
22
 
@@ -26,6 +27,7 @@ class PhraseLoopConfig:
26
27
  min_lines: int = 4
27
28
  top: int = 6
28
29
  max_overlap: float = 0.60
30
+ max_bar_phase_error: float | None = None
29
31
 
30
32
  def __post_init__(self):
31
33
  for key in ("min_seconds", "max_seconds", "max_overlap"):
@@ -38,6 +40,11 @@ class PhraseLoopConfig:
38
40
  raise ValueError("Require 0 < min_seconds <= max_seconds")
39
41
  if not 0 <= self.max_overlap < 1:
40
42
  raise ValueError("max_overlap must be in [0, 1)")
43
+ if self.max_bar_phase_error is not None:
44
+ value = self.max_bar_phase_error
45
+ if (isinstance(value, bool) or not isinstance(value, (float, int))
46
+ or not math.isfinite(value) or not 0 <= value <= .5):
47
+ raise ValueError('max_bar_phase_error must be None or finite in [0, .5]')
41
48
  for key, upper in (("min_lines", 128), ("top", 64)):
42
49
  value = getattr(self, key)
43
50
  if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= upper:
@@ -178,7 +185,7 @@ def analyze_phrase_loops(audio_path: Path, output_dir: Path, *, canonical: dict,
178
185
  source_hash = sha256_file(audio_path)
179
186
  lines = _canonical_lines(canonical, len(audio)/sr)
180
187
  ratings, excluded = _feedback(feedback, source_hash, len(audio), lines, sr)
181
- model, beats, _down, errors = _grid(beat_results, len(audio)/sr)
188
+ model, beats, down, errors = _grid(beat_results, len(audio)/sr)
182
189
  strict_cfg = LoopConfig(snap_ms=0)
183
190
  gates, vocal_errors = _gates(stems, len(audio)/sr, strict_cfg, normalization_gains or {})
184
191
  intervals, timing_sources = _vocal_intervals(whisper_result, lyrics_result, canonical)
@@ -208,6 +215,14 @@ def analyze_phrase_loops(audio_path: Path, output_dir: Path, *, canonical: dict,
208
215
  if not phase["available"]:
209
216
  rejected["missing_grid"] += 1
210
217
  continue
218
+ bar_phase = detected_bar_phase_metrics(beats, down, a/sr, b/sr)
219
+ if cfg.max_bar_phase_error is not None:
220
+ if not bar_phase['available']:
221
+ rejected['missing_bar_phase'] += 1
222
+ continue
223
+ if bar_phase['bar_phase_error'] > cfg.max_bar_phase_error+1e-12:
224
+ rejected['bar_phase_mismatch'] += 1
225
+ continue
211
226
  context = musical_restart_context(audio, sr, a, b,
212
227
  phase["local_period_seconds"], events)
213
228
  if not context["available"]:
@@ -233,7 +248,7 @@ def analyze_phrase_loops(audio_path: Path, output_dir: Path, *, canonical: dict,
233
248
  pool.append(dict(start_sample=a,end_sample=b,duration_samples=b-a,
234
249
  start_seconds=a/sr,end_seconds=b/sr,duration_seconds=duration,
235
250
  included_lyrics=lines[i:j+1],canonical_line_indices=[i,j],
236
- restart_context=context,phase=phase,join=join,
251
+ restart_context=context,phase=phase,detected_bar_phase=bar_phase,join=join,
237
252
  vocal_clear=all(acoustic_clear) and all(timing_clear),
238
253
  acoustic_clear=acoustic_clear,timing_clear=timing_clear,
239
254
  failed_gates=failed,ranking_cost=cost,feedback_duration_cost=duration_cost,
@@ -275,6 +290,7 @@ def analyze_phrase_loops(audio_path: Path, output_dir: Path, *, canonical: dict,
275
290
  "Bad section passages and exact canonical-word repeats excluded; all rated near-duplicates suppressed.",
276
291
  "Missing attack similarity stays null; available context weights are reported.",
277
292
  "Strict loop search and its acceptance gates are unchanged.",
293
+ "Optional detected bar-phase filter uses enclosing downbeats and consistent measured beats per bar; it does not prove meter, harmony or listening quality.",
278
294
  "Native audio slices only; no snap, fade, crossfade, stretch or normalisation."])
279
295
  _write_json(output_dir/"phrase_loops.json",report)
280
296
  return report
@@ -14,6 +14,7 @@ from rich.table import Table
14
14
  from .analysis.registry import ACTIONS
15
15
  from .bootstrap import bootstrap as do_bootstrap
16
16
  from .branding import AUTHOR_NAME, AUTHOR_SITE, MY_SONGS_URL, PROJECT_DESCRIPTION, REPOSITORY_URL
17
+ from .clip_cli import register_clip_commands
17
18
  from .cue_cli import print_cue_review, register_cue_commands
18
19
  from .models import MODEL_REGISTRY, profile as resolve_profile
19
20
  from .pipeline import run_pipeline
@@ -23,6 +24,7 @@ from .util import device_string
23
24
  app = typer.Typer(no_args_is_help=True, pretty_exceptions_show_locals=False, help=PROJECT_DESCRIPTION)
24
25
  console = Console()
25
26
  register_cue_commands(app, console)
27
+ register_clip_commands(app, console)
26
28
 
27
29
 
28
30
  @app.command("repair-spectrograms")
@@ -274,6 +276,8 @@ def find_phrase_loops(
274
276
  max_seconds: Annotated[float, typer.Option("--max-seconds", min=0.001)] = 30.0,
275
277
  min_lines: Annotated[int, typer.Option("--min-lines", min=1, max=128)] = 4,
276
278
  top: Annotated[int, typer.Option("--top", min=1, max=64)] = 6,
279
+ max_bar_phase_error: Annotated[float | None, typer.Option("--max-bar-phase-error", min=0, max=.5,
280
+ help="Optional maximum cut-position mismatch as a fraction of a measured bar")] = None,
277
281
  export_audio: Annotated[bool, typer.Option("--export-audio")] = False,
278
282
  ):
279
283
  """Suggest line-complete phrases by musical restart; never accept strict loops."""
@@ -284,7 +288,8 @@ def find_phrase_loops(
284
288
  ratings = json.loads(feedback.read_text(encoding="utf-8")) if feedback else None
285
289
  report = analyze_existing_phrase_loops(results, feedback=ratings, export_audio=export_audio,
286
290
  config=PhraseLoopConfig(min_seconds=min_seconds,max_seconds=max_seconds,
287
- min_lines=min_lines,top=top))
291
+ min_lines=min_lines,top=top,
292
+ max_bar_phase_error=max_bar_phase_error))
288
293
  except (ValueError, TypeError, KeyError, OSError) as exc:
289
294
  raise typer.BadParameter(str(exc)) from exc
290
295
  console.print(f"{report['candidate_count']} phrase audition candidate(s); "
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+
9
+ def register_clip_commands(app, console):
10
+ @app.command("match-clip")
11
+ def match_clip_command(
12
+ source: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
13
+ clip: Annotated[Path, typer.Argument(exists=True, dir_okay=False)],
14
+ output: Annotated[Path, typer.Option("--output", "-o", help="New evidence JSON file")],
15
+ sample_rate: Annotated[int, typer.Option("--sample-rate", min=2000, max=24000)] = 8000,
16
+ clip_start: Annotated[float, typer.Option("--clip-start", min=0)] = 0.0,
17
+ clip_end: Annotated[float | None, typer.Option("--clip-end", min=0)] = None,
18
+ ):
19
+ """Locate a contiguous audio/video excerpt in its source; no models run."""
20
+ from .clip_match import match_clip
21
+ from .util import write_json
22
+
23
+ if output.exists() or output.resolve() in (source.resolve(), clip.resolve()):
24
+ raise typer.BadParameter("Output must be a new file, separate from the inputs")
25
+ try:
26
+ report = match_clip(source, clip, sample_rate=sample_rate,
27
+ clip_start=clip_start, clip_end=clip_end)
28
+ except (OSError, ValueError) as exc:
29
+ raise typer.BadParameter(str(exc)) from exc
30
+ write_json(output, report)
31
+ console.print(f"{report['status']}: {report['reason']}")
32
+ console.print(str(output))
33
+ if report["status"] != "matched":
34
+ raise typer.Exit(2)
@@ -0,0 +1,190 @@
1
+ """Find a contiguous recording excerpt on its source clock, without models.
2
+
3
+ Matching proves a measured waveform relationship, never public-use rights or
4
+ artist approval. AAC/MP3 encoding and fades are tolerated; ambiguous repetition,
5
+ edit jumps, silence, different performances and rate changes remain unresolved.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+ from scipy.signal import correlate, resample_poly
16
+
17
+ from .util import sha256_file
18
+
19
+
20
+ def _samples(value: np.ndarray) -> np.ndarray:
21
+ value = np.asarray(value, dtype=np.float64)
22
+ if value.ndim == 1:
23
+ value = value[:, None]
24
+ if value.ndim != 2 or not value.size or not np.all(np.isfinite(value)):
25
+ raise ValueError("Audio must contain finite frames with one or two channels")
26
+ if value.shape[1] not in (1, 2):
27
+ raise ValueError("Only mono and stereo inputs are supported")
28
+ return value
29
+
30
+
31
+ def _correlation(source: np.ndarray, query: np.ndarray) -> np.ndarray:
32
+ """Sliding, channel-preserving, zero-mean normalised correlation."""
33
+ if source.shape[1] != query.shape[1]:
34
+ if source.shape[1] == 1:
35
+ source = np.repeat(source, query.shape[1], axis=1)
36
+ else:
37
+ query = np.repeat(query, source.shape[1], axis=1)
38
+ n = len(query)
39
+ query = query - query.mean(axis=0)
40
+ energy = float(np.sum(query * query))
41
+ if energy <= 1e-12:
42
+ return np.zeros(len(source) - n + 1)
43
+ numerator = np.zeros(len(source) - n + 1)
44
+ variance = np.zeros_like(numerator)
45
+ for channel in range(source.shape[1]):
46
+ signal = source[:, channel]
47
+ numerator += correlate(signal, query[:, channel], mode="valid", method="fft")
48
+ total = np.concatenate(([0.0], np.cumsum(signal)))
49
+ square = np.concatenate(([0.0], np.cumsum(signal * signal)))
50
+ sums = total[n:] - total[:-n]
51
+ variance += square[n:] - square[:-n] - sums * sums / n
52
+ norm = np.sqrt(np.maximum(variance, 0) * energy)
53
+ return np.clip(np.divide(numerator, norm, out=np.zeros_like(norm), where=norm > 1e-12), -1, 1)
54
+
55
+
56
+ def match_samples(source: np.ndarray, clip: np.ndarray, sample_rate: int, *,
57
+ min_correlation: float = 0.90, min_margin: float = 0.02,
58
+ tolerance_seconds: float = 0.002) -> dict:
59
+ """Return conservative timing evidence for already decoded equal-rate PCM."""
60
+ source, clip = _samples(source), _samples(clip)
61
+ if not isinstance(sample_rate, int) or sample_rate < 1:
62
+ raise ValueError("sample_rate must be a positive integer")
63
+ if (not math.isfinite(min_correlation) or not 0 < min_correlation <= 1 or
64
+ not math.isfinite(min_margin) or not 0 <= min_margin <= 1 or
65
+ not math.isfinite(tolerance_seconds) or tolerance_seconds <= 0):
66
+ raise ValueError("Invalid matching thresholds")
67
+ report = {"status": "unresolved", "mapping": None, "candidates": [], "anchors": [],
68
+ "analysis_sample_rate": sample_rate, "precision_seconds": 1 / sample_rate,
69
+ "source_duration_seconds": len(source) / sample_rate,
70
+ "clip_duration_seconds": len(clip) / sample_rate,
71
+ "thresholds": {"min_correlation": min_correlation, "min_margin": min_margin,
72
+ "offset_tolerance_seconds": tolerance_seconds}}
73
+ if len(clip) < 2 * sample_rate or len(clip) > len(source):
74
+ return {**report, "reason": "Clip needs at least two seconds and must fit in the source"}
75
+ edge = min(round(0.25 * sample_rate), len(clip) // 10)
76
+ query = clip[edge:len(clip) - edge]
77
+ if np.std(query) < 1e-7:
78
+ return {**report, "reason": "Silent or constant clip has insufficient timing information"}
79
+ scores = _correlation(source, query)
80
+ remaining = scores.copy()
81
+ exclusion = max(1, round(0.5 * sample_rate))
82
+ for _ in range(min(5, len(scores))):
83
+ index = int(np.argmax(remaining))
84
+ if not np.isfinite(remaining[index]):
85
+ break
86
+ report["candidates"].append({"offset_samples": index - edge,
87
+ "offset_seconds": (index - edge) / sample_rate,
88
+ "correlation": float(scores[index])})
89
+ remaining[max(0, index - exclusion):index + exclusion + 1] = -np.inf
90
+ best = report["candidates"][0]
91
+ offset = best["offset_samples"]
92
+ second = report["candidates"][1]["correlation"] if len(report["candidates"]) > 1 else 0
93
+ report["correlation_margin"] = best["correlation"] - second
94
+ if best["correlation"] < min_correlation:
95
+ return {**report, "reason": "No strong contiguous waveform match"}
96
+ if offset < 0 or offset + len(clip) > len(source):
97
+ return {**report, "reason": "Matched interval extends outside the source; inspect clip range"}
98
+ anchor_frames = min(sample_rate, len(query) // 3)
99
+ starts = np.linspace(edge, len(clip) - edge - anchor_frames, 5).round().astype(int)
100
+ search_radius = max(1, round(0.05 * sample_rate))
101
+ for start in starts:
102
+ expected = offset + int(start)
103
+ left = max(0, expected - search_radius)
104
+ right = min(len(source), expected + anchor_frames + search_radius)
105
+ section = clip[start:start + anchor_frames]
106
+ if np.std(section) < 1e-7:
107
+ report["anchors"].append({"clip_time": start / sample_rate, "correlation": 0.0,
108
+ "informative": False, "offset_error_seconds": None})
109
+ continue
110
+ anchor_scores = _correlation(source[left:right], section)
111
+ index = int(np.argmax(anchor_scores))
112
+ actual_offset = left + index - int(start)
113
+ report["anchors"].append({"clip_time": start / sample_rate, "informative": True,
114
+ "correlation": float(anchor_scores[index]),
115
+ "offset_seconds": actual_offset / sample_rate,
116
+ "offset_error_seconds": (actual_offset - offset) / sample_rate})
117
+ reliable = [a for a in report["anchors"] if a["informative"]]
118
+ if len(reliable) < 3 or any(a["correlation"] < min_correlation or
119
+ abs(a["offset_error_seconds"]) > tolerance_seconds for a in reliable):
120
+ return {**report, "reason": "Independent windows disagree; possible edit, speed change or mismatch"}
121
+ if report["correlation_margin"] < min_margin:
122
+ return {**report, "reason": "Repeated audio gives ambiguous source positions"}
123
+ return {**report, "status": "matched", "reason": "Unique contiguous match with consistent independent windows",
124
+ "mapping": {"offset": offset / sample_rate, "duration": len(clip) / sample_rate,
125
+ "playerStart": 0.0}}
126
+
127
+
128
+ def decode_audio(path: Path, sample_rate: int = 8000, *, max_seconds: float = 1800) -> np.ndarray:
129
+ """Decode a local audio/video file; retain stereo phase and bound memory/time."""
130
+ import soundfile as sf
131
+
132
+ path = Path(path)
133
+ if not path.is_file():
134
+ raise ValueError("Expected an existing local audio or video file")
135
+ if not isinstance(sample_rate, int) or not 2000 <= sample_rate <= 24000:
136
+ raise ValueError("Analysis rate must be an integer from 2000 to 24000 Hz")
137
+ if not math.isfinite(max_seconds) or not 2 <= max_seconds <= 7200:
138
+ raise ValueError("Decode limit must be between two seconds and two hours")
139
+ try:
140
+ info = sf.info(str(path))
141
+ except (sf.LibsndfileError, RuntimeError):
142
+ info = None
143
+ if info is not None:
144
+ if info.duration > max_seconds or info.channels not in (1, 2):
145
+ raise ValueError("Audio exceeds the decode limit or is not mono/stereo")
146
+ data, native_rate = sf.read(str(path), dtype="float32", always_2d=True)
147
+ if native_rate != sample_rate:
148
+ divisor = math.gcd(native_rate, sample_rate)
149
+ data = resample_poly(data, sample_rate // divisor, native_rate // divisor, axis=0)
150
+ return _samples(data)
151
+ ffmpeg = shutil.which("ffmpeg")
152
+ if ffmpeg is None:
153
+ raise ValueError("FFmpeg is required to decode this local video/audio format")
154
+ result = subprocess.run([ffmpeg, "-nostdin", "-v", "error", "-i", str(path),
155
+ "-map", "0:a:0", "-vn", "-t", str(max_seconds + 0.1),
156
+ "-ac", "2", "-ar", str(sample_rate), "-f", "f32le", "pipe:1"],
157
+ capture_output=True, timeout=120, check=True)
158
+ data = np.frombuffer(result.stdout, dtype="<f4")
159
+ if data.size % 2 or data.size > max_seconds * sample_rate * 2:
160
+ raise ValueError("Decoded audio is incomplete or exceeds the decode limit")
161
+ return _samples(data.reshape(-1, 2))
162
+
163
+
164
+ def match_clip(source_path: Path, clip_path: Path, *, sample_rate: int = 8000,
165
+ clip_start: float = 0.0, clip_end: float | None = None,
166
+ min_correlation: float = 0.90, min_margin: float = 0.02) -> dict:
167
+ """Analyse immutable local files and return hash-bound source/player timing."""
168
+ source_path, clip_path = Path(source_path), Path(clip_path)
169
+ source_hash, clip_hash = sha256_file(source_path), sha256_file(clip_path)
170
+ source = decode_audio(source_path, sample_rate)
171
+ clip = decode_audio(clip_path, sample_rate)
172
+ if not math.isfinite(clip_start) or clip_start < 0:
173
+ raise ValueError("clip_start must be a finite non-negative time")
174
+ if clip_end is not None and (not math.isfinite(clip_end) or clip_end <= clip_start):
175
+ raise ValueError("clip_end must be finite and later than clip_start")
176
+ first = round(clip_start * sample_rate)
177
+ last = len(clip) if clip_end is None else round(clip_end * sample_rate)
178
+ if first >= len(clip) or last > len(clip):
179
+ raise ValueError("Selected clip interval must fit in the decoded audio")
180
+ report = match_samples(source, clip[first:last], sample_rate,
181
+ min_correlation=min_correlation, min_margin=min_margin)
182
+ if report["mapping"] is not None:
183
+ report["mapping"]["playerStart"] = first / sample_rate
184
+ from . import __version__
185
+ if source_hash != sha256_file(source_path) or clip_hash != sha256_file(clip_path):
186
+ raise ValueError("An input file changed during matching; discard this result")
187
+ return {"schema": "stemlab.clip-match.v1", "stemlab_version": __version__,
188
+ "source": {"path": str(source_path), "sha256": source_hash},
189
+ "clip": {"path": str(clip_path), "sha256": clip_hash},
190
+ "clip_range_seconds": [first / sample_rate, last / sample_rate], **report}