danceflow-stemlab 1.4.1__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.1 → danceflow_stemlab-1.4.2}/AGENTS.md +6 -0
  2. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/PKG-INFO +13 -1
  3. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/README.md +12 -0
  4. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/.codex-plugin/plugin.json +1 -1
  5. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/README.md +6 -0
  6. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/plugin.json +1 -1
  7. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/SKILL.md +9 -0
  8. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/PKG-INFO +13 -1
  9. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/SOURCES.txt +3 -0
  10. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/__init__.py +1 -1
  11. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/cli.py +2 -0
  12. danceflow_stemlab-1.4.2/src/stemlab/clip_cli.py +34 -0
  13. danceflow_stemlab-1.4.2/src/stemlab/clip_match.py +190 -0
  14. danceflow_stemlab-1.4.2/tests/test_clip_match.py +103 -0
  15. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/.agents/plugins/marketplace.json +0 -0
  16. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/.github/workflows/codex-plugin.yml +0 -0
  17. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/.github/workflows/release.yml +0 -0
  18. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/CONTRIBUTING.md +0 -0
  19. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/LICENSE +0 -0
  20. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/MANIFEST.in +0 -0
  21. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/branding/README.md +0 -0
  22. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/branding/logo-monochrome.svg +0 -0
  23. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/branding/logo.png +0 -0
  24. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/branding/logo.svg +0 -0
  25. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/codex-plugin.md +0 -0
  26. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/codex-publishing.md +0 -0
  27. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/cue-alignment.md +0 -0
  28. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/evidence-models.md +0 -0
  29. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/docs/midi.md +0 -0
  30. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/mcp-registry/server.json +0 -0
  31. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/.mcp.json +0 -0
  32. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo-monochrome.svg +0 -0
  33. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.png +0 -0
  34. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.svg +0 -0
  35. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/mcp.json +0 -0
  36. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/references/tools.md +0 -0
  37. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/pyproject.toml +0 -0
  38. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/benchmark_evidence_corpus.py +0 -0
  39. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/build_codex_plugin.py +0 -0
  40. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/capture_loop_workflow.py +0 -0
  41. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/check_codex_timeline.py +0 -0
  42. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/loop_demo.py +0 -0
  43. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/prepare_codex_marketplace.py +0 -0
  44. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/publish_mcp_registry.py +0 -0
  45. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/scripts/smoke_midi.py +0 -0
  46. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/setup.cfg +0 -0
  47. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/dependency_links.txt +0 -0
  48. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/entry_points.txt +0 -0
  49. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/requires.txt +0 -0
  50. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/top_level.txt +0 -0
  51. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/__main__.py +0 -0
  52. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/__init__.py +0 -0
  53. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/__init__.py +0 -0
  54. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/adapters.py +0 -0
  55. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/registry.py +0 -0
  56. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/runner.py +0 -0
  57. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/worker.py +0 -0
  58. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/harmony.py +0 -0
  59. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/loops.py +0 -0
  60. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/lyrics.py +0 -0
  61. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/__init__.py +0 -0
  62. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/adapters.py +0 -0
  63. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/artifacts.py +0 -0
  64. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/registry.py +0 -0
  65. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/runner.py +0 -0
  66. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/worker.py +0 -0
  67. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/phrase_loops.py +0 -0
  68. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/registry.py +0 -0
  69. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rhythm.py +0 -0
  70. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rudiment_matches.py +0 -0
  71. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/runner.py +0 -0
  72. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_audio.py +0 -0
  73. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_text.py +0 -0
  74. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/song_map.py +0 -0
  75. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/sonic.py +0 -0
  76. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/structure.py +0 -0
  77. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/tempo_regimes.py +0 -0
  78. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/transcription.py +0 -0
  79. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/audio.py +0 -0
  80. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/__init__.py +0 -0
  81. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/base.py +0 -0
  82. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatnet.py +0 -0
  83. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatthis.py +0 -0
  84. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beattransformer.py +0 -0
  85. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/beats/common.py +0 -0
  86. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/bootstrap.py +0 -0
  87. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/branding.py +0 -0
  88. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/canonical.py +0 -0
  89. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__init__.py +0 -0
  90. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__main__.py +0 -0
  91. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/bridge.py +0 -0
  92. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/jobs.py +0 -0
  93. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/paths.py +0 -0
  94. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/server.py +0 -0
  95. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/tasks.py +0 -0
  96. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/timeline.py +0 -0
  97. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/codex/worker.py +0 -0
  98. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/cue_cli.py +0 -0
  99. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/cues.py +0 -0
  100. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/lyric_lrc.py +0 -0
  101. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/manifest.py +0 -0
  102. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/models.py +0 -0
  103. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/pipeline.py +0 -0
  104. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/__init__.py +0 -0
  105. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/base.py +0 -0
  106. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/bsroformer.py +0 -0
  107. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/common.py +0 -0
  108. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/demucs.py +0 -0
  109. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/openunmix.py +0 -0
  110. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/separators/scnet.py +0 -0
  111. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/server.py +0 -0
  112. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/sonic_visualiser.py +0 -0
  113. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram.py +0 -0
  114. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram_repair.py +0 -0
  115. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/speech.py +0 -0
  116. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/types.py +0 -0
  117. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/util.py +0 -0
  118. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/vamp.py +0 -0
  119. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/vamp_runtime.py +0 -0
  120. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/app.js +0 -0
  121. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-cover.jpg +0 -0
  122. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-reference.json +0 -0
  123. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/index.html +0 -0
  124. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/style.css +0 -0
  125. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/web/timeline.css +0 -0
  126. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/webui.py +0 -0
  127. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/src/stemlab/word_alignment.py +0 -0
  128. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_analysis_registry.py +0 -0
  129. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_arcadians_fixture.py +0 -0
  130. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_arcadians_rudiments.py +0 -0
  131. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_audio_normalization.py +0 -0
  132. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_bar_phase.py +0 -0
  133. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_beat_consensus.py +0 -0
  134. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_beat_this_bootstrap.py +0 -0
  135. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_beatnet_bootstrap.py +0 -0
  136. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_beatnet_compat.py +0 -0
  137. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_branding.py +0 -0
  138. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_bsroformer_adapter.py +0 -0
  139. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_canonical.py +0 -0
  140. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_canonical_extended.py +0 -0
  141. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_bridge.py +0 -0
  142. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_jobs.py +0 -0
  143. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_package.py +0 -0
  144. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_protocol.py +0 -0
  145. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_release.py +0 -0
  146. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_codex_timeline.py +0 -0
  147. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_cue_integration.py +0 -0
  148. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_cues.py +0 -0
  149. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_deep_harmony.py +0 -0
  150. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_deep_lyrics.py +0 -0
  151. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_deep_sonic_rhythm.py +0 -0
  152. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_distribution_identity.py +0 -0
  153. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_docker_contract.py +0 -0
  154. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_evidence_models.py +0 -0
  155. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_loop_integration.py +0 -0
  156. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_loops.py +0 -0
  157. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_lyric_lrc.py +0 -0
  158. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_manifest.py +0 -0
  159. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_midi_adapters.py +0 -0
  160. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_midi_extraction.py +0 -0
  161. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_midi_integration.py +0 -0
  162. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_midi_registry.py +0 -0
  163. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_models.py +0 -0
  164. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_phrase_loops.py +0 -0
  165. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_repository_hygiene.py +0 -0
  166. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_rudiment_matches.py +0 -0
  167. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_server.py +0 -0
  168. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_song_map.py +0 -0
  169. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser.py +0 -0
  170. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser_workspace.py +0 -0
  171. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_spectrogram.py +0 -0
  172. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_speech_repetition.py +0 -0
  173. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_vamp.py +0 -0
  174. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_vamp_runtime.py +0 -0
  175. {danceflow_stemlab-1.4.1 → danceflow_stemlab-1.4.2}/tests/test_web_frontend.py +0 -0
  176. {danceflow_stemlab-1.4.1 → 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.1
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
@@ -431,6 +431,18 @@ These filters suggest auditions and do not establish seamless or listening appro
431
431
 
432
432
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
433
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
+
434
446
  ## Audio-to-MIDI model comparison
435
447
 
436
448
  MIDI extraction is an **optional, independent model pass**. It adds Transkun V2
@@ -304,6 +304,18 @@ These filters suggest auditions and do not establish seamless or listening appro
304
304
 
305
305
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
306
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
+
307
319
  ## Audio-to-MIDI model comparison
308
320
 
309
321
  MIDI extraction is an **optional, independent model pass**. It adds Transkun V2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stemlab",
3
- "version": "1.4.1",
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.1",
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",
@@ -141,4 +141,13 @@ readiness, and these outputs never rewrite canonical lyrics, sections or beat gr
141
141
 
142
142
  ## Improve StemLab
143
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
+
144
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.1
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
@@ -431,6 +431,18 @@ These filters suggest auditions and do not establish seamless or listening appro
431
431
 
432
432
  ![Loop selection and playback in the existing analysis frontend](docs/screenshots/loops-playing.png)
433
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
+
434
446
  ## Audio-to-MIDI model comparison
435
447
 
436
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
@@ -133,6 +135,7 @@ tests/test_branding.py
133
135
  tests/test_bsroformer_adapter.py
134
136
  tests/test_canonical.py
135
137
  tests/test_canonical_extended.py
138
+ tests/test_clip_match.py
136
139
  tests/test_codex_bridge.py
137
140
  tests/test_codex_jobs.py
138
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.1"
10
+ __version__ = "1.4.2"
@@ -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")
@@ -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}
@@ -0,0 +1,103 @@
1
+ from pathlib import Path
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import soundfile as sf
6
+ from scipy.signal import resample_poly
7
+ from typer.testing import CliRunner
8
+
9
+ from stemlab.clip_match import decode_audio, match_clip, match_samples
10
+
11
+
12
+ def recording(rate=4000, seconds=15):
13
+ rng = np.random.default_rng(4701)
14
+ return rng.normal(0, 0.1, (rate * seconds, 2))
15
+
16
+
17
+ def test_unique_gain_fade_noise_and_stereo_phase():
18
+ rate = 4000
19
+ source = recording(rate)
20
+ source[:, 1] = -source[:, 0]
21
+ offset = 21251
22
+ clip = source[offset:offset + 4 * rate].copy() * 0.5
23
+ clip[:rate // 4] *= np.linspace(0, 1, rate // 4)[:, None]
24
+ clip[-rate // 4:] *= np.linspace(1, 0, rate // 4)[:, None]
25
+ clip += np.random.default_rng(5).normal(0, 0.0001, clip.shape)
26
+ report = match_samples(source, clip, rate)
27
+ assert report['status'] == 'matched'
28
+ assert report['mapping']['offset'] == offset / rate
29
+ assert report['mapping']['duration'] == 4
30
+ assert len(report['anchors']) == 5
31
+
32
+
33
+ def test_identical_repeated_passage_stays_ambiguous():
34
+ rate = 4000
35
+ source = recording(rate)
36
+ source[8 * rate:12 * rate] = source[2 * rate:6 * rate]
37
+ report = match_samples(source, source[2 * rate:6 * rate], rate)
38
+ assert report['status'] == 'unresolved'
39
+ assert report['mapping'] is None
40
+ assert 'ambiguous' in report['reason']
41
+
42
+
43
+ @pytest.mark.parametrize('kind', ['silence', 'constant', 'different', 'edit', 'rate'])
44
+ def test_invalid_clock_relationship_is_not_auto_mapped(kind):
45
+ rate = 4000
46
+ source = recording(rate)
47
+ clip = source[2 * rate:6 * rate].copy()
48
+ if kind == 'silence':
49
+ clip[:] = 0
50
+ elif kind == 'constant':
51
+ clip[:] = 0.1
52
+ elif kind == 'different':
53
+ clip = np.random.default_rng(3).normal(0, 0.1, clip.shape)
54
+ elif kind == 'edit':
55
+ clip[2 * rate:] = source[10 * rate:12 * rate]
56
+ else:
57
+ clip = resample_poly(clip, 101, 100, axis=0)
58
+ result = match_samples(source, clip, rate)
59
+ assert result['status'] == 'unresolved'
60
+ assert result['mapping'] is None
61
+
62
+
63
+ @pytest.mark.parametrize('value', [float('nan'), float('inf'), -1, 0])
64
+ def test_invalid_thresholds(value):
65
+ with pytest.raises(ValueError):
66
+ match_samples(recording(), recording(seconds=3), 4000, min_correlation=value)
67
+
68
+
69
+ def test_local_hashes_range_and_rate_conversion(tmp_path):
70
+ rate = 8000
71
+ source = recording(rate)
72
+ source_path, clip_path = tmp_path / 'source.wav', tmp_path / 'clip.wav'
73
+ sf.write(source_path, source, rate, subtype='FLOAT')
74
+ # One second of unrelated material before the matching four-second excerpt.
75
+ clip = np.concatenate([recording(rate, 1), source[5 * rate:9 * rate]])
76
+ sf.write(clip_path, resample_poly(clip, 3, 2, axis=0), 12000, subtype='FLOAT')
77
+ result = match_clip(source_path, clip_path, clip_start=1, clip_end=5)
78
+ assert result['status'] == 'matched'
79
+ assert result['mapping'] == {'offset': 5.0, 'duration': 4.0, 'playerStart': 1.0}
80
+ assert len(result['source']['sha256']) == len(result['clip']['sha256']) == 64
81
+ assert result['schema'] == 'stemlab.clip-match.v1'
82
+ with pytest.raises(ValueError):
83
+ match_clip(source_path, clip_path, clip_end=7)
84
+ with pytest.raises(ValueError):
85
+ decode_audio(Path('https://example.invalid/source.mp4'))
86
+ with pytest.raises(ValueError):
87
+ decode_audio(source_path, max_seconds=2)
88
+
89
+
90
+ def test_cli_preserves_existing_inputs_and_reports_unresolved(tmp_path):
91
+ from stemlab.cli import app
92
+
93
+ source, clip, result = tmp_path / 'source.wav', tmp_path / 'clip.wav', tmp_path / 'match.json'
94
+ sf.write(source, recording(), 4000, subtype='FLOAT')
95
+ sf.write(clip, np.zeros((12000, 2)), 4000, subtype='FLOAT')
96
+ runner = CliRunner()
97
+ output = runner.invoke(app, ['match-clip', str(source), str(clip), '--output', str(result)])
98
+ assert output.exit_code == 2
99
+ assert result.is_file()
100
+ before = source.read_bytes()
101
+ output = runner.invoke(app, ['match-clip', str(source), str(clip), '--output', str(source)])
102
+ assert output.exit_code == 2
103
+ assert source.read_bytes() == before