src/colorsplitter/
core/ audio, embed, cluster, reduce, labels, cache, modelzoo, pipeline
models/ network definitions (voice encoder, wav2vec2, safetensors reader)
cli/ command line
web/ FastAPI app + the built frontend
training/ encoder training
frontend/ frontend sources; the build output lands in web/static
models/ registry.json only -- no weight binaries
tests/ including the equivalence suite
Layering rules that are worth keeping:
core/pipeline.pyis the single definition of what the tool does. The CLI and the web app are thin shells over it; no capability is reachable from one and not the other.coreimportsmodelslazily.import colorsplitterdoes not pull in torch, which is what lets the package be installed and scripted without it.- Cross-cutting state (the weights cache, the projection cache) lives in a dedicated cache directory, never inside the directory being scanned.
Only three things in the previous codebase were load-bearing, and all three were kept:
- The clustering algorithms (
core/cluster.py).SpectralClusterandUmapHdbscan, ported from 3D-Speaker. These were chosen deliberately and are not to be changed. - The encoder definition (
models/voice_encoder.py). Every checkpoint in existence is bound to this tensor layout, so it is effectively a frozen interface. - The emotion model definition, since it is the only thing that gives the
emotionandmixencoders meaning.
Everything else — five top-level scripts, a pile of visualisation helpers, and the standalone viewer — was replaced.
The algorithms are untouched. Parameter defaults (pval, min_pnum,
n_components, min_cluster_size) are untouched. What changed is how the same
arithmetic is executed:
p_pruningis vectorised.np.argsort(A, axis=1)produces exactly the per-row ordering the original Python loop produced, so the pruned matrix is bit-identical while the interpreter loop over rows disappears.max_num_spksis configurable. It used to be hard-coded to 14, which madenmin > 14contradictory.mer_cosis actually passed through. The CLI accepted--mer_cosineand then dropped it on the floor: the value was read into a local variable andmer_cos=Nonewas passed to the clusterer. It is now wired up, and validated (the old type declaration would have raisedTypeErrorin the assertion had it ever been connected).- An optional sparse eigensolver. With
eigen_solver="sparse", the pruned affinity is sparsified and ARPACK computes only the leading eigenpairs instead of a full O(N³) decomposition. It is opt-in;"auto"only selects it above 2048 samples.
That last one is an approximation, so it is not asserted as equality.
tests/test_cluster_equivalence.py checks two separate things:
| claim | assertion |
|---|---|
| the dense path reproduces the original | exact label equality against a verbatim copy of the old implementation |
| the sparse path agrees with the dense path | Adjusted Rand Index ≥ 0.95, measured and reported |
If the second ever drops below the threshold the fast path is not fit to be enabled, and the test says so rather than quietly tolerating it.
| before | after |
|---|---|
splitter.py etc., five scripts driven by input() and plt.show() |
core/pipeline.py, pure functions; CLI and WebUI on top |
move_files.py (copy) and kick.py (move) with no way to tell them apart |
one export with an explicit mode |
clean_csv.py, editing an annotation file in place |
read-only validation and report; the tool no longer knows what an annotation file is |
load_npy.py, drawing a plot from a .npy |
import embeddings in the WebUI, then cluster and review as usual |
| a viewer needing a second server on :8080 plus CORS, building audio URLs from filesystem paths | one process, one port, audio streamed from an opaque token with range support |
| caching keyed by speaker directory, inside the scanned data | content-fingerprint cache in a dedicated directory |
| emotion model loaded at import time | loaded on demand |
| weights committed to git | resolved through registry.json, downloaded, hash-verified |
- No tuning. The clustering and projection defaults were left exactly as they were. Changing them during a refactor would make it impossible to attribute a later difference in results to either the refactor or the tuning.
- No ONNX export. The encoder definition stays a torch module. Anything that needs a portable graph should be built on top of it rather than reshaping it.
- No history rewrite. The old weights are still in the git history. Removing them from the working tree is enough; rewriting history is a separate decision with separate consequences.
Both were verified against upstream source rather than recalled, because both fail silently:
- In
models/wav2vec2.py, the positional convolution uses weight normalisation withdim=2, which in PyTorch means the norm runs over every dimension except dim 2. The checkpoint'sweight_gof shape(1, 1, 128)only makes sense under that reading. wav2vec2.encoder.layer_normexists in the checkpoint but is never applied by the upstream forward pass. It is constructed here so the state dict loads cleanly, and deliberately kept out of the graph.test_encoder_layer_norm_is_inertperturbs it and asserts the output does not move, so that a well-meaning "fix" fails loudly instead of corrupting every embedding.