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.
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/AGENTS.md +6 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/PKG-INFO +16 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/README.md +15 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/mcp-registry/server.json +2 -2
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/.codex-plugin/plugin.json +1 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/README.md +6 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/plugin.json +1 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/SKILL.md +11 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/PKG-INFO +16 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/SOURCES.txt +4 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/__init__.py +1 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/loops.py +32 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/phrase_loops.py +19 -3
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cli.py +6 -1
- danceflow_stemlab-1.4.2/src/stemlab/clip_cli.py +34 -0
- danceflow_stemlab-1.4.2/src/stemlab/clip_match.py +190 -0
- danceflow_stemlab-1.4.2/tests/test_bar_phase.py +74 -0
- danceflow_stemlab-1.4.2/tests/test_clip_match.py +103 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_phrase_loops.py +3 -1
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.agents/plugins/marketplace.json +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.github/workflows/codex-plugin.yml +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/.github/workflows/release.yml +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/CONTRIBUTING.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/LICENSE +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/MANIFEST.in +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/README.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo-monochrome.svg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo.png +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/branding/logo.svg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/codex-plugin.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/codex-publishing.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/cue-alignment.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/evidence-models.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/docs/midi.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/.mcp.json +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo-monochrome.svg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.png +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/branding/logo.svg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/mcp.json +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/skills/stemlab/references/tools.md +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/pyproject.toml +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/benchmark_evidence_corpus.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/build_codex_plugin.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/capture_loop_workflow.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/check_codex_timeline.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/loop_demo.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/prepare_codex_marketplace.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/publish_mcp_registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/scripts/smoke_midi.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/setup.cfg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/dependency_links.txt +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/entry_points.txt +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/requires.txt +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/top_level.txt +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/__main__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/adapters.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/runner.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/evidence/worker.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/harmony.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/lyrics.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/adapters.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/artifacts.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/runner.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/midi/worker.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rhythm.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/rudiment_matches.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/runner.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_audio.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/semantic_text.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/song_map.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/sonic.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/structure.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/tempo_regimes.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/analysis/transcription.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/audio.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/base.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatnet.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beatthis.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/beattransformer.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/beats/common.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/bootstrap.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/branding.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/canonical.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/__main__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/bridge.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/jobs.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/paths.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/server.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/tasks.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/timeline.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/codex/worker.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cue_cli.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/cues.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/lyric_lrc.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/manifest.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/models.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/pipeline.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/__init__.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/base.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/bsroformer.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/common.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/demucs.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/openunmix.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/separators/scnet.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/server.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/sonic_visualiser.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/spectrogram_repair.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/speech.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/types.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/util.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/vamp.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/vamp_runtime.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/app.js +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-cover.jpg +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/arcadians-reference.json +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/index.html +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/style.css +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/web/timeline.css +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/webui.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/stemlab/word_alignment.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_analysis_registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_arcadians_fixture.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_arcadians_rudiments.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_audio_normalization.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beat_consensus.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beat_this_bootstrap.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beatnet_bootstrap.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_beatnet_compat.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_branding.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_bsroformer_adapter.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_canonical.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_canonical_extended.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_bridge.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_jobs.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_package.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_protocol.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_release.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_codex_timeline.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_cue_integration.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_cues.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_harmony.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_lyrics.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_deep_sonic_rhythm.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_distribution_identity.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_docker_contract.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_evidence_models.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_loop_integration.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_loops.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_lyric_lrc.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_manifest.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_adapters.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_extraction.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_integration.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_midi_registry.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_models.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_repository_hygiene.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_rudiment_matches.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_server.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_song_map.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_sonic_visualiser_workspace.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_spectrogram.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_speech_repetition.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_vamp.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_vamp_runtime.py +0 -0
- {danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/tests/test_web_frontend.py +0 -0
- {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.
|
|
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
|

|
|
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
|

|
|
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.
|
|
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.
|
|
16
|
+
"version": "1.4.1",
|
|
17
17
|
"runtimeHint": "uvx",
|
|
18
18
|
"runtimeArguments": [
|
|
19
19
|
{
|
{danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/plugins/stemlab/.codex-plugin/plugin.json
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stemlab",
|
|
3
|
-
"version": "1.4.
|
|
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.
|
|
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.
|
|
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
|

|
|
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
|
{danceflow_stemlab-1.4.0 → danceflow_stemlab-1.4.2}/src/danceflow_stemlab.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -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,
|
|
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,
|
|
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}
|