Repository navigation
feat: bundle GGUF format (ASR, VAD, diarization, sound events and voice in one file) - #85
Conversation
A bundle packs several single-model GGUF files into one file, so a user passes one file. Every tensor and key of a component is stored with the prefix <component>. and the header lists the components with their kind, licence, licence URL, source, credit and changes. Tensors are copied byte for byte, so each component keeps its quantisation. docs/bundle.md specifies the layout, the versioning and compatibility rules (plain files unchanged, old readers refuse a bundle), the selection rules and the licence handling. scripts/bundle_gguf.py builds a bundle from a JSON manifest. It checks every input, refuses a component with missing licence metadata, a non-commercial licence or a licence that contradicts the input file, records the sha256 of each input and a content digest of each component, and writes the same bytes for the same inputs. --list, --verify (with optional comparison against the source files) and --notice inspect a bundle. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
ModelLoader::load_component opens a bundle with no_alloc, then reads only the tensors of one component into a private context. The component's keys and tensors are seen under their original names, so the rest of the engine treats it like a single-model file. Model::load and SileroVad::load take an optional component name. ModelLoader::load and SileroVad::load without a component refuse a bundle with a message that says so. Plain files take the same path as before. C-API, all additive (no ABI bump): parakeet_ctx* parakeet_capi_load_component(const char* path, const char* name); char* parakeet_capi_bundle_components_json(const char* path); const char* parakeet_capi_load_error(void); parakeet_capi_load on a bundle opens the only ASR component, or its only loadable component, and fails with a message that lists the names when there is more than one candidate. A Silero component gives a VAD context, so the existing vad and external-VAD functions work with it. test_bundle writes synthetic bundles and checks the header, selection, tensor bytes, the bytes read per component, the Silero component against the plain file, malformed and truncated bundles, the C-API calls and that an old reader finds none of its keys. test_bundle_models compares a real bundle with its single-model files. check_bundle covers the script. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
info lists the components of a bundle with their kind, licence, source, credit, changes and size, read from the header only; --component NAME prints the details of one component. transcribe and vad take --component. A bundle opens its only ASR component by default, and a bundle with several candidates is refused with the list of names. For vad, and for transcribe --vad, the VAD source on a bundle is the Silero component if there is one, otherwise the VAD head of the ASR component. --component (vad) and --vad-component (transcribe) override this; --vad-model still takes precedence. Other subcommands do not take a bundle yet and refuse it with a message. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Conflict in tests/CMakeLists.txt: keep both the check_bundle test and the check_publish_hf_licences test. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Master gained VAD-only GGUF slices (parakeet.arch "vad", Model::load_vad_only). Keep both features: * Model::load keeps its component argument and refuses a slice; Model::load_vad_only takes an optional component too. * A bundle component of kind "vad" may be a Silero model or a slice. The two are told apart by the parakeet.arch key (bundle_vad_is_slice). parakeet_capi_load_component gives a VAD-only context for a slice; parakeet-cli vad and info handle it. * bundle_gguf.py accepts a slice as kind vad and refuses it as asr. * transcribe --vad skips slice components when it looks for Silero. Conflicts were in examples/cli/main.cpp, src/model.hpp and tests/CMakeLists.txt. Tests cover a synthetic slice in a bundle and a python build and verify case. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
A bundle component of kind "diar" holds a Nemotron diarization model. DiarizationModel::load takes an optional component and reads it through the same prefixed ModelLoader as an ASR component, so only that component's tensors are read. parakeet_capi_load_component gives a diarization context for it, which every diarization, speaker-attributed ASR, scene and named-diarization call accepts as for a single-model file. The kinds ced and voice are known to the header reader and to the default-component choice; their loaders follow. The diarize tool takes a bundle and uses its only diar component, or the one named with --component. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
ced.cpp and voice-detect.cpp open a model by file path only, and their sources stay unchanged. A component of kind "ced" or "voice" is therefore written as a standalone single-model GGUF (keys and tensors without the component prefix, data streamed from the bundle) and the path of that copy is given to the loader. On Linux the copy is an anonymous in-memory file (memfd_create, opened as /proc/self/fd/N); elsewhere, or with PARAKEET_BUNDLE_NO_MEMFD set, it is an owner-only temporary file that is removed after the load. CedTagger::load and SpeakerEncoder::load take an optional component and an error string. parakeet_capi_load_component handles the kinds ced and voice; the speaker identity of a voice component is the sha256 of the single-model file it was built from, so profiles made with the standalone file match. parakeet-cli: scene takes a bundle for each of --model, --diar, --sound and --speakers (the only component of the right kind, or one named with --asr-component, --diar-component, --sound-component or --speakers-component); enroll takes --component; info describes ced and voice components. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
bundle_gguf.py builds the kinds diar, ced and voice and checks each input against its kind (a diarization GGUF, a ced.cpp GGUF, a speaker encoder with an embedding). Known sources must carry the agreed licence: Nemotron diarization OpenMDW-1.1, CED, ECAPA and ERes2Net Apache-2.0, WeSpeaker CC-BY-4.0 (the stricter of the two readings upstream), Silero MIT. The build refuses models that must not be bundled: the audeering heads (CC-BY-NC-SA) and the end-of-utterance model (NVIDIA Open Model License), by source, licence or input name. --notice prints each component's credit and then the full text of every licence used, taken from scripts/bundle_licenses/. It fails when a licence has no text there. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Kinds, selection rules for commands that need one kind, the standalone copy used for the ced.cpp and voice-detect.cpp loaders (memfd and the temporary-file fallback), the follow-up of a load_from_buffer hook in those projects, the licence table with the WeSpeaker discrepancy, and what is not verified. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
test_bundle checks the standalone copy of a component against the single-model file (memfd and temporary file, no file left behind). test_bundle_full compares a real bundle with its source files: ASR transcript, diarization JSON, CED scores and speaker embedding bitwise, speaker identity, Silero probabilities, named diarization with bundle contexts, partial reads, selection rules and the refusal by the unmodified third-party loaders. check_bundle.py covers the new kinds, the forbidden models, wrong licences and the NOTICE texts. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
|
Scope update: this PR now covers the full bundle, not only phase 1. The title and description above describe the first commits. Components (one bundle GGUF can hold any of them;
ced.cpp and voice-detect.cpp open a model by path only, and their sources are unchanged. A C-API (additive, ABI stays 10): Licences: each component carries its own licence, credit and changes. Tests: |
The voice component is converted from the WeSpeaker ResNet34-LM model, so name that source, credit the WeSpeaker project and keep CC-BY-4.0. Name the ModelScope source of ERes2Net. Record that the licence of the CED weights is not consistent upstream (model cards say Apache-2.0, the code repository is GPL-3.0, the checkpoint records say CC-BY-4.0) and should be confirmed with the authors. Add a note on VoxCeleb training data and suggested credit strings for the voice encoders. The NOTICE preamble no longer says the weights are unchanged. It says each component is copied byte for byte from its converted file and that the Changes line states any conversion or quantisation. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
What and why
Today a user who wants transcription plus voice activity detection, diarization, sound events and speaker naming passes several GGUF files. This PR adds a bundle GGUF: one file that holds several components. Each component has its own namespaced tensors and keys, and its own licence, source and credit. There is no requantisation: every tensor keeps the type it was published with, so an F16 ASR model can sit next to a Q8_0 diarization model. Plain GGUF files load unchanged. An old reader refuses a bundle cleanly.
Format and rules are in
docs/bundle.md.Components
asrvadSileroVadorModel::load_vad_onlydiarDiarizationModel::load(path, component)cedvoiceced.cpp and voice-detect.cpp can only load from a path. For those two kinds the component is copied out as a standalone GGUF (an in-memory file through
memfd_createon Linux, an owner-only temporary file that is removed after loading elsewhere;PARAKEET_BUNDLE_NO_MEMFD=1forces the file) and the path is handed to the unmodified loader. Only that component's bytes are read. Aload_from_bufferhook in those two repos would remove the copy; that is a follow-up.C-API (additive, ABI 10 unchanged)
parakeet_capi_load_component(path, name): opensasr,vad,diar,ced,voiceand VAD-only slice components.parakeet_capi_bundle_components_json(path): lists components with kind, licence, source, credit, hashes and sizes.parakeet_capi_load_error(): thread-local reason of the last failed load.parakeet_capi_loadon a bundle opens the onlyasrcomponent, or lists the names when it is ambiguous.voicecomponent reports its speaker identity assha256:plus thesource_sha256from the bundle header, so profiles made with the standalone file match.CLI
parakeet-cli info <file>: components, licences and sizes (header only).transcribe,vad,diarize,enroll:--model bundle.gguf [--component NAME];transcribe --vad [--vad-component NAME].scene: a bundle works for--model,--diar,--soundand--speakers;--asr-component,--diar-component,--sound-componentand--speakers-componentpick a component when there is more than one of a kind.bench, streaming and other subcommands refuse a bundle with a clear message.scripts/bundle_gguf.py
Builds a bundle from a JSON manifest. Modes:
--list,--verify(recomputes every tensor hash and checks keys),--notice(prints the credits and the full licence texts of every component, fromscripts/bundle_licenses/). It refuses components under NC or ND licences, with a missing or empty licence, licence URL or credit, with a licence that contradicts the input's own, and known sources with the wrong licence. It refuses the audeering heads and the NVIDIA Open Model License models by source, licence or name. Output is deterministic.Tests
test_bundle(model independent): synthetic bundles, header parsing, selection rules, tensor bytes, bytes read per component, malformed and truncated files, the standalone copy forcedandvoicein both the memfd and temporary-file paths with nothing left behind, all C-API calls.tests/python/check_bundle.py: build, verify and refusal cases, NOTICE texts.test_bundle_modelsandtest_bundle_full(labelmodel): a real bundle against its source files: ASR transcript, diarization JSON, CED scores and speaker embedding bitwise, speaker identity, Silero probabilities, named diarization, partial reads, refusal by the unmodified third-party loaders.ctest -LE model: 34 of 34. With the model environment, the only failures aretest_relpos_attention_local_chunkedandtest_capi_timestamps(known) andtest_combined_offline(45 against 65 streaming words), which fails the same way on a build without the bundle code.Results on two real bundles (not published)
For both:
--verifyagainst all source files matches tensor for tensor; the transcript, diarization segments,sceneoutput with all four models, named speakers, CED scores, speaker embeddings andenrollregistries equal those of the standalone files; two builds of the same manifest are byte-identical. Loading only the Silero component reads 1.7 MB of the 1.1 GB file; loading a CED component reads about twice its size (the copy plus the loader's read).Limits
docs/bundle.md). The WeSpeaker licence is recorded as CC-BY-4.0, the stricter of two readings; the shipped OpenMDW text was taken from openmdw.ai and needs a check.Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]
🤖 Generated with Claude Code