Skip to content

feat: bundle GGUF format (ASR, VAD, diarization, sound events and voice in one file) - #85

Merged
mudler merged 13 commits into
masterfrom
feat/bundle-gguf-p1
Oct 4, 2026
Merged

mudler merged 13 commits into
masterfrom
feat/bundle-gguf-p1

Conversation

@localai-org-maint-bot

@localai-org-maint-bot localai-org-maint-bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

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

Kind What it holds How it loads
asr a Parakeet or Nemotron model, with its own VAD head if it has one the normal loader, through a prefixed view
vad Silero VAD, or a VAD-only slice from #87 SileroVad or Model::load_vad_only
diar Nemotron-3-Diarization DiarizationModel::load(path, component)
ced a CED sound-event model (ced.cpp) the unmodified ced.cpp loader, see below
voice a speaker encoder (voice-detect.cpp) the unmodified voice-detect.cpp loader, see below

ced.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_create on Linux, an owner-only temporary file that is removed after loading elsewhere; PARAKEET_BUNDLE_NO_MEMFD=1 forces the file) and the path is handed to the unmodified loader. Only that component's bytes are read. A load_from_buffer hook 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): opens asr, vad, diar, ced, voice and 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_load on a bundle opens the only asr component, or lists the names when it is ambiguous.
  • A voice component reports its speaker identity as sha256: plus the source_sha256 from 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, --sound and --speakers; --asr-component, --diar-component, --sound-component and --speakers-component pick 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, from scripts/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 for ced and voice in 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_models and test_bundle_full (label model): 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 are test_relpos_attention_local_chunked and test_capi_timestamps (known) and test_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)

Bundle Contents Size
small 110M Q8_0, Nemotron-3-Diarization Q8_0, CED-small Q8_0, WeSpeaker F32, Silero F16 337,885,152 bytes
standard tdt-0.6b-v3 Q8_0, the same diarization, CED, WeSpeaker and Silero 1,100,752,640 bytes

For both: --verify against all source files matches tensor for tensor; the transcript, diarization segments, scene output with all four models, named speakers, CED scores, speaker embeddings and enroll registries 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

  • GPU and streaming ASR through a bundle are not verified. Other ASR families, K-quant components, CED-tiny and CED-base were not tried.
  • The temporary-file fallback is written for Windows and macOS but was not run there.
  • ECAPA tensor names are 61 bytes long, so in a bundle it needs a one-letter component name (the script limit is 63 bytes).
  • An old reader refuses a bundle cleanly, but it reads the whole file first.
  • No bundle is published or announced. When one is shipped, the full licence texts of every component must ship with it (see "Licences" in 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

mudler added 5 commits October 3, 2026 22:55
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]
mudler added 6 commits October 4, 2026 06:59
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]
@localai-org-maint-bot

Copy link
Copy Markdown
Collaborator Author

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; docs/bundle.md has the format):

  • asr: a Parakeet ASR model, with its VAD head if it has one (Ultra, Redux)
  • vad: a Silero model, or a VAD-only slice from feat: VAD-only GGUF files (head, front end, subsampler) and a slicer #87 (told apart by parakeet.arch)
  • diar: Nemotron diarization, read natively through the same prefixed loader as asr
  • ced: ced.cpp sound events
  • voice: voice-detect.cpp speaker encoders (WeSpeaker, ECAPA, ERes2Net, CAM++)

ced.cpp and voice-detect.cpp open a model by path only, and their sources are unchanged. A ced or voice component is written as a standalone GGUF (an in-memory file via memfd_create on Linux, else a temporary file removed after the load) and that path is passed to the loader. The output is bitwise equal to the single-model file in the tests. A load_from_buffer hook in those two projects would remove the copy; it is noted in the docs as a follow-up.

C-API (additive, ABI stays 10): parakeet_capi_load_component now opens diar, ced, voice and slice components; parakeet_capi_load still picks the ASR component. CLI: scene takes a bundle for --model, --diar, --sound, --speakers (component by kind, or --asr-component, --diar-component, --sound-component, --speakers-component), enroll --component, diarize takes a bundle, info describes every kind.

Licences: each component carries its own licence, credit and changes. scripts/bundle_gguf.py checks known sources against agreed licences (Nemotron diarization OpenMDW-1.1, CED, ECAPA and ERes2Net Apache-2.0, WeSpeaker CC-BY-4.0, Silero MIT), refuses the audeering heads and the EOU model, and --notice prints the credits plus the full licence texts to ship.

Tests: test_bundle (synthetic, includes the standalone copy through memfd and the temporary-file path), check_bundle.py (kinds, refusals, NOTICE), and the model tests test_bundle_models and test_bundle_full (a real bundle equals the single-model files: transcript, diarization JSON, CED scores, speaker embedding, Silero probabilities).

@localai-org-maint-bot localai-org-maint-bot changed the title feat: bundle GGUF format (ASR, VAD head, Silero in one file) feat: bundle GGUF format (ASR, VAD, diarization, sound events and voice in one file) Oct 4, 2026
mudler added 2 commits October 4, 2026 11:30
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]
@mudler
mudler merged commit dc10c93 into master Oct 4, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants