From ee4cd2cc70a800dd0c73860f421992cc68542b98 Mon Sep 17 00:00:00 2001 From: Justin Chu Date: Fri, 4 Sep 2026 10:05:41 -0700 Subject: [PATCH 1/4] Add VibeVoice 1.5B Transformers release tooling Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu --- README.md | 3 +- docs/publish-vibevoice-1.5b-hf.md | 81 ++++ docs/vibevoice-tts.md | 1 + tests/test_publish_vibevoice_1_5b_hf.py | 174 ++++++++ tools/release/convert_vibevoice_1_5b_hf.py | 450 +++++++++++++++++++++ 5 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 docs/publish-vibevoice-1.5b-hf.md create mode 100644 tests/test_publish_vibevoice_1_5b_hf.py create mode 100644 tools/release/convert_vibevoice_1_5b_hf.py diff --git a/README.md b/README.md index 57496590..0e01ff2c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ For more information, demos, and examples, please visit our [Project Page](https | VibeVoice-ASR-7B | [HF Link](https://huggingface.co/microsoft/VibeVoice-ASR) | [Playground](https://aka.ms/vibevoice-asr) | | VibeVoice-ASR-Streaming | [HF Link](https://huggingface.co/collections/microsoft/vibevoice-68a2ef24a875c44be47b034f) | [Documentation](docs/vibevoice-asr-streaming.md) | | VibeVoice-ASR-BitNet (CPU) | [HF Link](https://huggingface.co/microsoft/VibeVoice-ASR-BitNet) | [VibeASR.cpp](https://github.com/microsoft/VibeASR.cpp) | -| VibeVoice-TTS-1.5B | [HF Link](https://huggingface.co/microsoft/VibeVoice-1.5B) | Disabled | +| VibeVoice-TTS-1.5B (legacy source) | [HF Link](https://huggingface.co/microsoft/VibeVoice-1.5B) | Disabled | +| VibeVoice-TTS-1.5B (Transformers-native) | [HF Link](https://huggingface.co/microsoft/VibeVoice-1.5B-HF) | [Publication workflow](docs/publish-vibevoice-1.5b-hf.md) | | VibeVoice-Realtime-0.5B | [HF Link](https://huggingface.co/microsoft/VibeVoice-Realtime-0.5B) | [Colab](https://colab.research.google.com/github/microsoft/VibeVoice/blob/main/demo/vibevoice_realtime_colab.ipynb) | diff --git a/docs/publish-vibevoice-1.5b-hf.md b/docs/publish-vibevoice-1.5b-hf.md new file mode 100644 index 00000000..1fae623c --- /dev/null +++ b/docs/publish-vibevoice-1.5b-hf.md @@ -0,0 +1,81 @@ +# Publishing VibeVoice 1.5B for Transformers + +This release path creates `microsoft/VibeVoice-1.5B-HF`, the official +Transformers-native artifact for the original TTS checkpoint. The original +[`microsoft/VibeVoice-1.5B`](https://huggingface.co/microsoft/VibeVoice-1.5B) +remains the legacy source/provenance repository; link it to `-HF` after the +owner publication rather than replacing its main branch. + +## Pinned inputs + +| Input | Pinned revision | Use | +| --- | --- | --- | +| [`microsoft/VibeVoice-1.5B`](https://huggingface.co/microsoft/VibeVoice-1.5B) | `c00898d257e6b46004e3e2866a47534085fb685a` | Official weight source | +| [Transformers](https://github.com/huggingface/transformers) | `640a08a597034221ca1c4fc0c129cf0118179225` | Canonical VibeVoice converter and native implementation | +| [`Qwen/Qwen2.5-1.5B`](https://huggingface.co/Qwen/Qwen2.5-1.5B) | `8faed761d45a263340a0528343f099c05c9a4323` | Canonical tokenizer input | +| [`vibevoice/VibeVoice-1.5B-hf`](https://huggingface.co/vibevoice/VibeVoice-1.5B-hf) | `edc39f80f5cae656da37baf8faa8f5502bf7081f` | Independent 1,204-key layout evidence only | + +The Transformers commit contains `VibeVoiceForConditionalGeneration`, +`VibeVoiceProcessor`, `AutoModelForTextToWaveform` registration, and the +canonical `convert_vibevoice_to_hf.py` converter. It postdates the v5.16.1 +release, so use the pinned source commit rather than a released package. + +The sidecar reference above is never downloaded by the converter or used at +runtime. The converter downloads only the official Microsoft checkpoint and +the pinned Qwen tokenizer, invokes the canonical Transformers converter, and +fails unless all 1,204 source-to-native keys, shapes, and dtypes agree. + +## Owner conversion and publication + +Run this in a clean Python environment with sufficient disk and memory for the +multi-GB checkpoint: + +```bash +git clone https://github.com/huggingface/transformers.git /tmp/transformers +git -C /tmp/transformers checkout 640a08a597034221ca1c4fc0c129cf0118179225 +python -m pip install -e "/tmp/transformers[torch]" + +python tools/release/convert_vibevoice_1_5b_hf.py \ + --transformers-source /tmp/transformers \ + --output-dir /tmp/VibeVoice-1.5B-HF +``` + +The output includes native safetensor shards, config, generation config, +tokenizer, processor, chat template, model card, and +`conversion-manifest.json`. The manifest records every pinned input and the +strict tensor-alignment digest. The script has no upload option. + +Only an owner authorized for the Microsoft Hugging Face namespace should +publish the reviewed local artifact: + +```bash +huggingface-cli upload microsoft/VibeVoice-1.5B-HF /tmp/VibeVoice-1.5B-HF . \ + --commit-message "Publish Transformers-native VibeVoice 1.5B" +``` + +## Acceptance + +The unit checks do not download model weights: + +```bash +python -m unittest discover -s tests -p 'test_publish_vibevoice_1_5b_hf.py' -v +``` + +The conversion command performs the optional real-weight validation. After the +owner publishes, this native-only load confirms the public artifact has no +remote code or sidecar dependency: + +```python +from transformers import AutoModelForTextToWaveform, AutoProcessor + +model_id = "microsoft/VibeVoice-1.5B-HF" +processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=False) +model = AutoModelForTextToWaveform.from_pretrained( + model_id, + dtype="auto", + trust_remote_code=False, +) + +assert processor.__class__.__name__ == "VibeVoiceProcessor" +assert model.__class__.__name__ == "VibeVoiceForConditionalGeneration" +``` diff --git a/docs/vibevoice-tts.md b/docs/vibevoice-tts.md index a3a5f777..e1b2a9aa 100644 --- a/docs/vibevoice-tts.md +++ b/docs/vibevoice-tts.md @@ -8,6 +8,7 @@ **Model:** [VibeVoice-1.5B](https://huggingface.co/microsoft/VibeVoice-1.5B)
**Report:** [Technical Report](https://arxiv.org/pdf/2508.19205)
+**Transformers-native publication:** [VibeVoice-1.5B-HF](https://huggingface.co/microsoft/VibeVoice-1.5B-HF) ([owner workflow](publish-vibevoice-1.5b-hf.md))
diff --git a/tests/test_publish_vibevoice_1_5b_hf.py b/tests/test_publish_vibevoice_1_5b_hf.py new file mode 100644 index 00000000..24d5735a --- /dev/null +++ b/tests/test_publish_vibevoice_1_5b_hf.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "tools" / "release" / "convert_vibevoice_1_5b_hf.py" +SPEC = importlib.util.spec_from_file_location("publish_vibevoice", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +PUBLISH = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = PUBLISH +SPEC.loader.exec_module(PUBLISH) + + +def write_safetensors_header(path: Path, tensors: dict[str, dict[str, object]]) -> None: + header = json.dumps(tensors, separators=(",", ":")).encode() + path.write_bytes(len(header).to_bytes(8, "little") + header) + + +def write_checkpoint(directory: Path, tensors: dict[str, dict[str, object]]) -> None: + shard = "model-00001-of-00001.safetensors" + write_safetensors_header(directory / shard, tensors) + (directory / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {name: shard for name in tensors}}), + encoding="utf-8", + ) + + +class PublishVibeVoiceTests(unittest.TestCase): + def test_pinned_revisions_are_full_git_hashes(self) -> None: + for revision in ( + PUBLISH.OFFICIAL_SOURCE_REVISION, + PUBLISH.TRANSFORMERS_REVISION, + PUBLISH.QWEN_TOKENIZER_REVISION, + PUBLISH.NATIVE_REFERENCE_REVISION, + ): + self.assertRegex(revision, r"^[0-9a-f]{40}$") + + def test_reads_metadata_from_indexed_safetensors_headers(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + checkpoint = Path(temporary_directory) + write_checkpoint( + checkpoint, + { + "tensor_a": {"dtype": "BF16", "shape": [2, 3], "data_offsets": [0, 12]}, + "tensor_b": {"dtype": "F32", "shape": [], "data_offsets": [12, 16]}, + }, + ) + + metadata = PUBLISH.checkpoint_tensor_metadata(checkpoint, expected_tensor_count=2) + + self.assertEqual(metadata["tensor_a"].shape, (2, 3)) + self.assertEqual(metadata["tensor_a"].dtype, "BF16") + self.assertEqual(metadata["tensor_b"].shape, ()) + + def test_rejects_index_header_key_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + checkpoint = Path(temporary_directory) + write_safetensors_header( + checkpoint / "model-00001-of-00001.safetensors", + {"actual": {"dtype": "BF16", "shape": [1], "data_offsets": [0, 2]}}, + ) + (checkpoint / "model.safetensors.index.json").write_text( + json.dumps({"weight_map": {"indexed": "model-00001-of-00001.safetensors"}}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(PUBLISH.ConversionError, "index/header mismatch"): + PUBLISH.checkpoint_tensor_metadata(checkpoint, expected_tensor_count=1) + + def test_rejects_dtype_or_shape_mismatch(self) -> None: + source = {"original": PUBLISH.TensorMetadata(shape=(2, 3), dtype="BF16")} + native = {"native": PUBLISH.TensorMetadata(shape=(2, 3), dtype="F32")} + + with self.assertRaisesRegex(PUBLISH.ConversionError, "metadata mismatch"): + PUBLISH.assert_tensor_metadata_matches( + source, + native, + lambda _: "native", + expected_tensor_count=1, + ) + + def test_rejects_key_mismatch(self) -> None: + source = {"original": PUBLISH.TensorMetadata(shape=(2, 3), dtype="BF16")} + native = {"other": PUBLISH.TensorMetadata(shape=(2, 3), dtype="BF16")} + + with self.assertRaisesRegex(PUBLISH.ConversionError, "Tensor key mismatch"): + PUBLISH.assert_tensor_metadata_matches( + source, + native, + lambda _: "native", + expected_tensor_count=1, + ) + + def test_rejects_remote_code_metadata(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) + for name in PUBLISH.REQUIRED_OUTPUT_FILES: + (output / name).write_text("{}", encoding="utf-8") + (output / "config.json").write_text( + json.dumps( + { + "model_type": "vibevoice", + "architectures": ["VibeVoiceForConditionalGeneration"], + } + ), + encoding="utf-8", + ) + (output / "processor_config.json").write_text( + json.dumps({"processor_class": "VibeVoiceProcessor"}), + encoding="utf-8", + ) + (output / "tokenizer_config.json").write_text( + json.dumps({"auto_map": {"AutoTokenizer": "untrusted.Module"}}), + encoding="utf-8", + ) + + with self.assertRaisesRegex(PUBLISH.ConversionError, "remote custom code"): + PUBLISH.assert_native_assets(output) + + def test_alignment_digest_is_deterministic(self) -> None: + source = { + "first": PUBLISH.TensorMetadata(shape=(1,), dtype="BF16"), + "second": PUBLISH.TensorMetadata(shape=(2,), dtype="F32"), + } + native = { + "native.first": source["first"], + "native.second": source["second"], + } + + first = PUBLISH.assert_tensor_metadata_matches( + source, + native, + lambda name: f"native.{name}", + expected_tensor_count=2, + ) + second = PUBLISH.assert_tensor_metadata_matches( + dict(reversed(list(source.items()))), + native, + lambda name: f"native.{name}", + expected_tensor_count=2, + ) + + self.assertEqual(first, second) + + def test_manifest_records_pinned_provenance(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + output = Path(temporary_directory) + (output / "config.json").write_text("{}", encoding="utf-8") + PUBLISH.write_manifest(output, "f" * 64) + manifest = json.loads((output / "conversion-manifest.json").read_text(encoding="utf-8")) + + self.assertEqual( + manifest["source"]["revision"], + PUBLISH.OFFICIAL_SOURCE_REVISION, + ) + self.assertEqual( + manifest["canonical_converter"]["revision"], + PUBLISH.TRANSFORMERS_REVISION, + ) + self.assertEqual(manifest["tensor_alignment"]["source_tensor_count"], 1204) + self.assertEqual( + manifest["native_reference"]["revision"], + PUBLISH.NATIVE_REFERENCE_REVISION, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/release/convert_vibevoice_1_5b_hf.py b/tools/release/convert_vibevoice_1_5b_hf.py new file mode 100644 index 00000000..568b233b --- /dev/null +++ b/tools/release/convert_vibevoice_1_5b_hf.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Create the official Transformers-native VibeVoice 1.5B release artifact.""" + +from __future__ import annotations + +import argparse +import gc +import importlib.util +import json +import subprocess +import sys +from dataclasses import asdict, dataclass +from hashlib import sha256 +from pathlib import Path +from typing import Any, Callable + + +OFFICIAL_SOURCE_REPOSITORY = "microsoft/VibeVoice-1.5B" +OFFICIAL_SOURCE_REVISION = "c00898d257e6b46004e3e2866a47534085fb685a" +TRANSFORMERS_REPOSITORY = "https://github.com/huggingface/transformers.git" +TRANSFORMERS_REVISION = "640a08a597034221ca1c4fc0c129cf0118179225" +QWEN_TOKENIZER_REPOSITORY = "Qwen/Qwen2.5-1.5B" +QWEN_TOKENIZER_REVISION = "8faed761d45a263340a0528343f099c05c9a4323" +NATIVE_REFERENCE_REPOSITORY = "vibevoice/VibeVoice-1.5B-hf" +NATIVE_REFERENCE_REVISION = "edc39f80f5cae656da37baf8faa8f5502bf7081f" +EXPECTED_TENSOR_COUNT = 1204 + +REQUIRED_OUTPUT_FILES = ( + "README.md", + "chat_template.jinja", + "config.json", + "generation_config.json", + "model.safetensors.index.json", + "processor_config.json", + "tokenizer.json", + "tokenizer_config.json", +) + + +class ConversionError(RuntimeError): + """Raised when a release input or converted artifact is not exact.""" + + +@dataclass(frozen=True) +class TensorMetadata: + shape: tuple[int, ...] + dtype: str + + +def load_json(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as handle: + value = json.load(handle) + if not isinstance(value, dict): + raise ConversionError(f"{path} must contain a JSON object.") + return value + + +def read_safetensors_header(path: Path) -> dict[str, TensorMetadata]: + with path.open("rb") as handle: + header_size_bytes = handle.read(8) + if len(header_size_bytes) != 8: + raise ConversionError(f"{path} is not a valid safetensors file.") + header_size = int.from_bytes(header_size_bytes, "little") + if header_size <= 0 or header_size > path.stat().st_size - 8: + raise ConversionError(f"{path} has an invalid safetensors header size.") + header = json.loads(handle.read(header_size)) + + if not isinstance(header, dict): + raise ConversionError(f"{path} has an invalid safetensors header.") + + tensors: dict[str, TensorMetadata] = {} + for name, entry in header.items(): + if name == "__metadata__": + continue + if not isinstance(name, str) or not isinstance(entry, dict): + raise ConversionError(f"{path} contains an invalid tensor entry.") + dtype = entry.get("dtype") + shape = entry.get("shape") + if not isinstance(dtype, str) or not isinstance(shape, list) or any( + not isinstance(dimension, int) or dimension < 0 for dimension in shape + ): + raise ConversionError(f"{path} has invalid metadata for {name!r}.") + tensors[name] = TensorMetadata(shape=tuple(shape), dtype=dtype) + return tensors + + +def checkpoint_tensor_metadata( + checkpoint_dir: Path, expected_tensor_count: int | None = None +) -> dict[str, TensorMetadata]: + index_path = checkpoint_dir / "model.safetensors.index.json" + if not index_path.is_file(): + raise ConversionError(f"Missing safetensors index: {index_path}") + + index = load_json(index_path) + weight_map = index.get("weight_map") + if not isinstance(weight_map, dict) or not all( + isinstance(name, str) and isinstance(shard, str) for name, shard in weight_map.items() + ): + raise ConversionError(f"{index_path} has an invalid weight_map.") + if expected_tensor_count is not None and len(weight_map) != expected_tensor_count: + raise ConversionError( + f"{index_path} has {len(weight_map)} tensors; expected {expected_tensor_count}." + ) + + tensors: dict[str, TensorMetadata] = {} + for shard_name in sorted(set(weight_map.values())): + shard_path = checkpoint_dir / shard_name + if not shard_path.is_file(): + raise ConversionError(f"Index references missing shard: {shard_path}") + shard_tensors = read_safetensors_header(shard_path) + for name, metadata in shard_tensors.items(): + if name in tensors: + raise ConversionError(f"Tensor {name!r} occurs in more than one shard.") + tensors[name] = metadata + + indexed_names = set(weight_map) + actual_names = set(tensors) + if indexed_names != actual_names: + missing = sorted(indexed_names - actual_names) + extra = sorted(actual_names - indexed_names) + raise ConversionError( + f"Safetensors index/header mismatch: missing={missing[:3]}, extra={extra[:3]}." + ) + return tensors + + +def mapping_digest(mapping: dict[str, tuple[str, TensorMetadata]]) -> str: + payload = [ + { + "native_name": native_name, + "source_name": source_name, + "shape": list(metadata.shape), + "dtype": metadata.dtype, + } + for native_name, (source_name, metadata) in sorted(mapping.items()) + ] + return sha256(json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()).hexdigest() + + +def assert_tensor_metadata_matches( + source: dict[str, TensorMetadata], + native: dict[str, TensorMetadata], + map_key: Callable[[str], str], + expected_tensor_count: int = EXPECTED_TENSOR_COUNT, +) -> str: + if len(source) != expected_tensor_count or len(native) != expected_tensor_count: + raise ConversionError( + f"Expected {expected_tensor_count} tensors, got source={len(source)}, native={len(native)}." + ) + + mapped: dict[str, tuple[str, TensorMetadata]] = {} + for source_name, metadata in source.items(): + native_name = map_key(source_name) + if native_name in mapped: + raise ConversionError(f"Two source tensors map to {native_name!r}.") + mapped[native_name] = (source_name, metadata) + + missing = sorted(set(mapped) - set(native)) + extra = sorted(set(native) - set(mapped)) + if missing or extra: + raise ConversionError( + f"Tensor key mismatch after conversion: missing={missing[:3]}, extra={extra[:3]}." + ) + + mismatches = [ + name + for name, (_, metadata) in mapped.items() + if native[name].shape != metadata.shape or native[name].dtype != metadata.dtype + ] + if mismatches: + name = mismatches[0] + raise ConversionError( + f"Tensor metadata mismatch for {name!r}: " + f"source={asdict(mapped[name][1])}, native={asdict(native[name])}." + ) + return mapping_digest(mapped) + + +def assert_transformers_revision(transformers_source: Path) -> Path: + transformers_source = transformers_source.resolve() + converter_path = ( + transformers_source + / "src" + / "transformers" + / "models" + / "vibevoice" + / "convert_vibevoice_to_hf.py" + ) + if not converter_path.is_file(): + raise ConversionError(f"Pinned VibeVoice converter is absent: {converter_path}") + + result = subprocess.run( + ["git", "-C", str(transformers_source), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise ConversionError(f"Cannot resolve the Transformers checkout: {result.stderr.strip()}") + actual_revision = result.stdout.strip() + if actual_revision != TRANSFORMERS_REVISION: + raise ConversionError( + f"Transformers must be at {TRANSFORMERS_REVISION}, got {actual_revision}." + ) + return converter_path + + +def import_canonical_converter(transformers_source: Path) -> Any: + converter_path = assert_transformers_revision(transformers_source) + source_root = transformers_source.resolve() / "src" + sys.path.insert(0, str(source_root)) + spec = importlib.util.spec_from_file_location("pinned_vibevoice_converter", converter_path) + if spec is None or spec.loader is None: + raise ConversionError(f"Cannot import canonical converter: {converter_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + import transformers + + transformers_path = Path(transformers.__file__).resolve() + if source_root not in transformers_path.parents: + raise ConversionError(f"Converter imported Transformers from {transformers_path}, not {source_root}.") + return module + + +def download_official_checkpoint() -> Path: + try: + from huggingface_hub import HfApi, snapshot_download + except ImportError as error: + raise ConversionError("Install huggingface_hub before running this converter.") from error + + info = HfApi().model_info( + repo_id=OFFICIAL_SOURCE_REPOSITORY, + revision=OFFICIAL_SOURCE_REVISION, + ) + if info.sha != OFFICIAL_SOURCE_REVISION: + raise ConversionError( + f"Hub resolved {OFFICIAL_SOURCE_REPOSITORY} to {info.sha}, " + f"not {OFFICIAL_SOURCE_REVISION}." + ) + return Path( + snapshot_download( + repo_id=OFFICIAL_SOURCE_REPOSITORY, + revision=OFFICIAL_SOURCE_REVISION, + allow_patterns=( + "config.json", + "preprocessor_config.json", + "model.safetensors.index.json", + "model-*.safetensors", + ), + ) + ) + + +def convert_with_canonical_tool( + converter: Any, source_dir: Path, output_dir: Path +) -> None: + canonical_snapshot_download = converter.snapshot_download + canonical_tokenizer_loader = converter.Qwen2TokenizerFast.from_pretrained + + def use_pinned_official_snapshot(repo_id: str, **_: Any) -> str: + if repo_id != OFFICIAL_SOURCE_REPOSITORY: + raise ConversionError(f"Canonical converter requested unexpected checkpoint: {repo_id}") + return str(source_dir) + + def load_pinned_qwen_tokenizer( + pretrained_model_name_or_path: str, *args: Any, **kwargs: Any + ) -> Any: + if pretrained_model_name_or_path != QWEN_TOKENIZER_REPOSITORY: + raise ConversionError( + "Canonical converter requested unexpected tokenizer source: " + f"{pretrained_model_name_or_path}" + ) + kwargs["revision"] = QWEN_TOKENIZER_REVISION + return canonical_tokenizer_loader(pretrained_model_name_or_path, *args, **kwargs) + + converter.snapshot_download = use_pinned_official_snapshot + converter.Qwen2TokenizerFast.from_pretrained = load_pinned_qwen_tokenizer + try: + converter.convert_checkpoint( + OFFICIAL_SOURCE_REPOSITORY, + str(output_dir), + push_to_hub=None, + bfloat16=True, + max_shard_size="2GB", + ) + finally: + converter.snapshot_download = canonical_snapshot_download + converter.Qwen2TokenizerFast.from_pretrained = canonical_tokenizer_loader + + +def assert_native_assets(output_dir: Path) -> dict[str, TensorMetadata]: + missing = [name for name in REQUIRED_OUTPUT_FILES if not (output_dir / name).is_file()] + if missing: + raise ConversionError(f"Converted artifact is missing required files: {', '.join(missing)}") + if list(output_dir.rglob("*.py")): + raise ConversionError("Converted artifact must not include Python sidecar code.") + + config = load_json(output_dir / "config.json") + if config.get("model_type") != "vibevoice": + raise ConversionError("Converted config must use model_type='vibevoice'.") + if "VibeVoiceForConditionalGeneration" not in config.get("architectures", []): + raise ConversionError("Converted config does not declare VibeVoiceForConditionalGeneration.") + + for name in ("config.json", "processor_config.json", "tokenizer_config.json"): + auto_map = load_json(output_dir / name).get("auto_map") + if auto_map: + raise ConversionError(f"{name} requests remote custom code through auto_map.") + if load_json(output_dir / "processor_config.json").get("processor_class") != "VibeVoiceProcessor": + raise ConversionError("Converted processor must be VibeVoiceProcessor.") + return checkpoint_tensor_metadata(output_dir, EXPECTED_TENSOR_COUNT) + + +def write_model_card(output_dir: Path) -> None: + (output_dir / "README.md").write_text( + f"""--- +license: mit +library_name: transformers +pipeline_tag: text-to-speech +base_model: {OFFICIAL_SOURCE_REPOSITORY} +--- + +# VibeVoice 1.5B (Transformers-native) + +This checkpoint is the official Transformers-native publication of +[`{OFFICIAL_SOURCE_REPOSITORY}`](https://huggingface.co/{OFFICIAL_SOURCE_REPOSITORY}). +It was converted from source revision +[`{OFFICIAL_SOURCE_REVISION}`](https://huggingface.co/{OFFICIAL_SOURCE_REPOSITORY}/tree/{OFFICIAL_SOURCE_REVISION}) +with the canonical Transformers converter at +[`{TRANSFORMERS_REVISION}`](https://github.com/huggingface/transformers/commit/{TRANSFORMERS_REVISION}). + +Load it with `AutoProcessor` and `AutoModelForTextToWaveform`; it does not require +`trust_remote_code` or a sidecar repository. `conversion-manifest.json` records the +complete release provenance and strict tensor-alignment result. +""", + encoding="utf-8", + ) + + +def write_manifest(output_dir: Path, mapping_sha256: str) -> None: + manifest = { + "format_version": 1, + "source": { + "repository": OFFICIAL_SOURCE_REPOSITORY, + "revision": OFFICIAL_SOURCE_REVISION, + }, + "canonical_converter": { + "repository": TRANSFORMERS_REPOSITORY, + "revision": TRANSFORMERS_REVISION, + }, + "tokenizer_source": { + "repository": QWEN_TOKENIZER_REPOSITORY, + "revision": QWEN_TOKENIZER_REVISION, + }, + "native_reference": { + "repository": NATIVE_REFERENCE_REPOSITORY, + "revision": NATIVE_REFERENCE_REVISION, + "purpose": "Independent key-layout evidence only; never downloaded by this workflow.", + }, + "tensor_alignment": { + "source_tensor_count": EXPECTED_TENSOR_COUNT, + "native_tensor_count": EXPECTED_TENSOR_COUNT, + "source_to_native_mapping_sha256": mapping_sha256, + }, + "output_files": sorted( + path.relative_to(output_dir).as_posix() + for path in output_dir.iterdir() + if path.name != "conversion-manifest.json" + ), + } + (output_dir / "conversion-manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def assert_native_auto_loading(output_dir: Path) -> None: + from transformers import AutoModelForTextToWaveform, AutoProcessor + + processor = AutoProcessor.from_pretrained( + output_dir, + local_files_only=True, + trust_remote_code=False, + ) + if processor.__class__.__name__ != "VibeVoiceProcessor": + raise ConversionError(f"AutoProcessor loaded {processor.__class__.__name__}, not VibeVoiceProcessor.") + + model = AutoModelForTextToWaveform.from_pretrained( + output_dir, + dtype="auto", + local_files_only=True, + trust_remote_code=False, + ) + if model.__class__.__name__ != "VibeVoiceForConditionalGeneration": + raise ConversionError( + f"AutoModelForTextToWaveform loaded {model.__class__.__name__}, " + "not VibeVoiceForConditionalGeneration." + ) + del model + gc.collect() + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--transformers-source", + type=Path, + required=True, + help=f"Transformers checkout pinned to {TRANSFORMERS_REVISION}.", + ) + parser.add_argument( + "--output-dir", + type=Path, + required=True, + help="New, empty local directory for the owner-upload artifact.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + output_dir = args.output_dir.resolve() + if output_dir.exists() and any(output_dir.iterdir()): + raise ConversionError(f"Output directory must be empty: {output_dir}") + output_dir.mkdir(parents=True, exist_ok=True) + + converter = import_canonical_converter(args.transformers_source) + print(f"Downloading {OFFICIAL_SOURCE_REPOSITORY}@{OFFICIAL_SOURCE_REVISION}") + source_dir = download_official_checkpoint() + source_metadata = checkpoint_tensor_metadata(source_dir, EXPECTED_TENSOR_COUNT) + + convert_with_canonical_tool(converter, source_dir, output_dir) + write_model_card(output_dir) + native_metadata = assert_native_assets(output_dir) + mapping_sha256 = assert_tensor_metadata_matches( + source_metadata, + native_metadata, + converter.map_old_key_to_new, + ) + write_manifest(output_dir, mapping_sha256) + assert_native_auto_loading(output_dir) + print(f"Validated {EXPECTED_TENSOR_COUNT} tensors and wrote {output_dir}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ConversionError as error: + raise SystemExit(f"Conversion failed: {error}") from error From 94d53e2c8ad67f9e22a41ab9a3caeaed38ee5686 Mon Sep 17 00:00:00 2001 From: Justin Chu Date: Fri, 4 Sep 2026 10:10:18 -0700 Subject: [PATCH 2/4] Require a clean pinned Transformers checkout Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu --- tests/test_publish_vibevoice_1_5b_hf.py | 33 ++++++++++++++++++++++ tools/release/convert_vibevoice_1_5b_hf.py | 11 ++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/test_publish_vibevoice_1_5b_hf.py b/tests/test_publish_vibevoice_1_5b_hf.py index 24d5735a..37c00048 100644 --- a/tests/test_publish_vibevoice_1_5b_hf.py +++ b/tests/test_publish_vibevoice_1_5b_hf.py @@ -6,6 +6,7 @@ import tempfile import unittest from pathlib import Path +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] @@ -41,6 +42,38 @@ def test_pinned_revisions_are_full_git_hashes(self) -> None: ): self.assertRegex(revision, r"^[0-9a-f]{40}$") + def test_rejects_dirty_transformers_checkout(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + checkout = Path(temporary_directory) + converter = ( + checkout + / "src" + / "transformers" + / "models" + / "vibevoice" + / "convert_vibevoice_to_hf.py" + ) + converter.parent.mkdir(parents=True) + converter.touch() + with patch.object( + PUBLISH.subprocess, + "run", + side_effect=[ + PUBLISH.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=f"{PUBLISH.TRANSFORMERS_REVISION}\n", + ), + PUBLISH.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=" M src/transformers/models/vibevoice/modular_vibevoice.py\n", + ), + ], + ): + with self.assertRaisesRegex(PUBLISH.ConversionError, "tracked changes"): + PUBLISH.assert_transformers_revision(checkout) + def test_reads_metadata_from_indexed_safetensors_headers(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: checkpoint = Path(temporary_directory) diff --git a/tools/release/convert_vibevoice_1_5b_hf.py b/tools/release/convert_vibevoice_1_5b_hf.py index 568b233b..befde32c 100644 --- a/tools/release/convert_vibevoice_1_5b_hf.py +++ b/tools/release/convert_vibevoice_1_5b_hf.py @@ -202,6 +202,17 @@ def assert_transformers_revision(transformers_source: Path) -> Path: raise ConversionError( f"Transformers must be at {TRANSFORMERS_REVISION}, got {actual_revision}." ) + + clean_result = subprocess.run( + ["git", "-C", str(transformers_source), "status", "--porcelain", "--untracked-files=no"], + check=False, + capture_output=True, + text=True, + ) + if clean_result.returncode: + raise ConversionError(f"Cannot inspect Transformers checkout: {clean_result.stderr.strip()}") + if clean_result.stdout: + raise ConversionError("Transformers checkout has tracked changes; use a clean pinned checkout.") return converter_path From 8beca1435017e1861ccb0f3e2e2f717a5ce3da20 Mon Sep 17 00:00:00 2001 From: Justin Chu Date: Fri, 4 Sep 2026 10:14:27 -0700 Subject: [PATCH 3/4] Reject untracked files in converter checkout Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu --- tests/test_publish_vibevoice_1_5b_hf.py | 34 +++++++++++++++++++++- tools/release/convert_vibevoice_1_5b_hf.py | 4 +-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/test_publish_vibevoice_1_5b_hf.py b/tests/test_publish_vibevoice_1_5b_hf.py index 37c00048..d9107b14 100644 --- a/tests/test_publish_vibevoice_1_5b_hf.py +++ b/tests/test_publish_vibevoice_1_5b_hf.py @@ -71,7 +71,39 @@ def test_rejects_dirty_transformers_checkout(self) -> None: ), ], ): - with self.assertRaisesRegex(PUBLISH.ConversionError, "tracked changes"): + with self.assertRaisesRegex(PUBLISH.ConversionError, "uncommitted changes"): + PUBLISH.assert_transformers_revision(checkout) + + def test_rejects_untracked_transformers_file(self) -> None: + with tempfile.TemporaryDirectory() as temporary_directory: + checkout = Path(temporary_directory) + converter = ( + checkout + / "src" + / "transformers" + / "models" + / "vibevoice" + / "convert_vibevoice_to_hf.py" + ) + converter.parent.mkdir(parents=True) + converter.touch() + with patch.object( + PUBLISH.subprocess, + "run", + side_effect=[ + PUBLISH.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=f"{PUBLISH.TRANSFORMERS_REVISION}\n", + ), + PUBLISH.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="?? src/transformers/sidecar.py\n", + ), + ], + ): + with self.assertRaisesRegex(PUBLISH.ConversionError, "uncommitted changes"): PUBLISH.assert_transformers_revision(checkout) def test_reads_metadata_from_indexed_safetensors_headers(self) -> None: diff --git a/tools/release/convert_vibevoice_1_5b_hf.py b/tools/release/convert_vibevoice_1_5b_hf.py index befde32c..83463e04 100644 --- a/tools/release/convert_vibevoice_1_5b_hf.py +++ b/tools/release/convert_vibevoice_1_5b_hf.py @@ -204,7 +204,7 @@ def assert_transformers_revision(transformers_source: Path) -> Path: ) clean_result = subprocess.run( - ["git", "-C", str(transformers_source), "status", "--porcelain", "--untracked-files=no"], + ["git", "-C", str(transformers_source), "status", "--porcelain"], check=False, capture_output=True, text=True, @@ -212,7 +212,7 @@ def assert_transformers_revision(transformers_source: Path) -> Path: if clean_result.returncode: raise ConversionError(f"Cannot inspect Transformers checkout: {clean_result.stderr.strip()}") if clean_result.stdout: - raise ConversionError("Transformers checkout has tracked changes; use a clean pinned checkout.") + raise ConversionError("Transformers checkout has uncommitted changes; use a clean pinned checkout.") return converter_path From 20a383a1cb65c2dc0de5f20c720a569f78f207e8 Mon Sep 17 00:00:00 2001 From: Justin Chu Date: Fri, 4 Sep 2026 10:18:17 -0700 Subject: [PATCH 4/4] Record the release tooling revision Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Justin Chu --- docs/publish-vibevoice-1.5b-hf.md | 3 +- tests/test_publish_vibevoice_1_5b_hf.py | 19 ++++++- tools/release/convert_vibevoice_1_5b_hf.py | 59 ++++++++++++++-------- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/docs/publish-vibevoice-1.5b-hf.md b/docs/publish-vibevoice-1.5b-hf.md index 1fae623c..df63f5d6 100644 --- a/docs/publish-vibevoice-1.5b-hf.md +++ b/docs/publish-vibevoice-1.5b-hf.md @@ -43,7 +43,8 @@ python tools/release/convert_vibevoice_1_5b_hf.py \ The output includes native safetensor shards, config, generation config, tokenizer, processor, chat template, model card, and `conversion-manifest.json`. The manifest records every pinned input and the -strict tensor-alignment digest. The script has no upload option. +strict tensor-alignment digest, including the clean VibeVoice release-tool +commit. The script has no upload option. Only an owner authorized for the Microsoft Hugging Face namespace should publish the reviewed local artifact: diff --git a/tests/test_publish_vibevoice_1_5b_hf.py b/tests/test_publish_vibevoice_1_5b_hf.py index d9107b14..88bbc7f8 100644 --- a/tests/test_publish_vibevoice_1_5b_hf.py +++ b/tests/test_publish_vibevoice_1_5b_hf.py @@ -106,6 +106,22 @@ def test_rejects_untracked_transformers_file(self) -> None: with self.assertRaisesRegex(PUBLISH.ConversionError, "uncommitted changes"): PUBLISH.assert_transformers_revision(checkout) + def test_records_clean_release_tool_checkout(self) -> None: + expected_revision = "a" * 40 + with patch.object( + PUBLISH.subprocess, + "run", + side_effect=[ + PUBLISH.subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=f"{expected_revision}\n", + ), + PUBLISH.subprocess.CompletedProcess(args=[], returncode=0, stdout=""), + ], + ): + self.assertEqual(PUBLISH.release_tool_revision(), expected_revision) + def test_reads_metadata_from_indexed_safetensors_headers(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: checkpoint = Path(temporary_directory) @@ -217,7 +233,7 @@ def test_manifest_records_pinned_provenance(self) -> None: with tempfile.TemporaryDirectory() as temporary_directory: output = Path(temporary_directory) (output / "config.json").write_text("{}", encoding="utf-8") - PUBLISH.write_manifest(output, "f" * 64) + PUBLISH.write_manifest(output, "f" * 64, "a" * 40) manifest = json.loads((output / "conversion-manifest.json").read_text(encoding="utf-8")) self.assertEqual( @@ -228,6 +244,7 @@ def test_manifest_records_pinned_provenance(self) -> None: manifest["canonical_converter"]["revision"], PUBLISH.TRANSFORMERS_REVISION, ) + self.assertEqual(manifest["release_tool"]["revision"], "a" * 40) self.assertEqual(manifest["tensor_alignment"]["source_tensor_count"], 1204) self.assertEqual( manifest["native_reference"]["revision"], diff --git a/tools/release/convert_vibevoice_1_5b_hf.py b/tools/release/convert_vibevoice_1_5b_hf.py index 83463e04..31e64b6c 100644 --- a/tools/release/convert_vibevoice_1_5b_hf.py +++ b/tools/release/convert_vibevoice_1_5b_hf.py @@ -23,6 +23,7 @@ QWEN_TOKENIZER_REVISION = "8faed761d45a263340a0528343f099c05c9a4323" NATIVE_REFERENCE_REPOSITORY = "vibevoice/VibeVoice-1.5B-hf" NATIVE_REFERENCE_REVISION = "edc39f80f5cae656da37baf8faa8f5502bf7081f" +RELEASE_TOOL_REPOSITORY = "https://github.com/microsoft/VibeVoice.git" EXPECTED_TENSOR_COUNT = 1204 REQUIRED_OUTPUT_FILES = ( @@ -176,6 +177,29 @@ def assert_tensor_metadata_matches( return mapping_digest(mapped) +def git_head_from_clean_checkout(checkout: Path, description: str) -> str: + result = subprocess.run( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode: + raise ConversionError(f"Cannot resolve {description} checkout: {result.stderr.strip()}") + + clean_result = subprocess.run( + ["git", "-C", str(checkout), "status", "--porcelain", "--untracked-files=all"], + check=False, + capture_output=True, + text=True, + ) + if clean_result.returncode: + raise ConversionError(f"Cannot inspect {description} checkout: {clean_result.stderr.strip()}") + if clean_result.stdout: + raise ConversionError(f"{description} checkout has uncommitted changes; use a clean checkout.") + return result.stdout.strip() + + def assert_transformers_revision(transformers_source: Path) -> Path: transformers_source = transformers_source.resolve() converter_path = ( @@ -189,33 +213,19 @@ def assert_transformers_revision(transformers_source: Path) -> Path: if not converter_path.is_file(): raise ConversionError(f"Pinned VibeVoice converter is absent: {converter_path}") - result = subprocess.run( - ["git", "-C", str(transformers_source), "rev-parse", "HEAD"], - check=False, - capture_output=True, - text=True, - ) - if result.returncode: - raise ConversionError(f"Cannot resolve the Transformers checkout: {result.stderr.strip()}") - actual_revision = result.stdout.strip() + actual_revision = git_head_from_clean_checkout(transformers_source, "Transformers") if actual_revision != TRANSFORMERS_REVISION: raise ConversionError( f"Transformers must be at {TRANSFORMERS_REVISION}, got {actual_revision}." ) - - clean_result = subprocess.run( - ["git", "-C", str(transformers_source), "status", "--porcelain"], - check=False, - capture_output=True, - text=True, - ) - if clean_result.returncode: - raise ConversionError(f"Cannot inspect Transformers checkout: {clean_result.stderr.strip()}") - if clean_result.stdout: - raise ConversionError("Transformers checkout has uncommitted changes; use a clean pinned checkout.") return converter_path +def release_tool_revision() -> str: + repository_root = Path(__file__).resolve().parents[2] + return git_head_from_clean_checkout(repository_root, "VibeVoice release tooling") + + def import_canonical_converter(transformers_source: Path) -> Any: converter_path = assert_transformers_revision(transformers_source) source_root = transformers_source.resolve() / "src" @@ -348,7 +358,7 @@ def write_model_card(output_dir: Path) -> None: ) -def write_manifest(output_dir: Path, mapping_sha256: str) -> None: +def write_manifest(output_dir: Path, mapping_sha256: str, tool_revision: str) -> None: manifest = { "format_version": 1, "source": { @@ -359,6 +369,10 @@ def write_manifest(output_dir: Path, mapping_sha256: str) -> None: "repository": TRANSFORMERS_REPOSITORY, "revision": TRANSFORMERS_REVISION, }, + "release_tool": { + "repository": RELEASE_TOOL_REPOSITORY, + "revision": tool_revision, + }, "tokenizer_source": { "repository": QWEN_TOKENIZER_REPOSITORY, "revision": QWEN_TOKENIZER_REVISION, @@ -430,6 +444,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: def main(argv: list[str] | None = None) -> int: args = parse_args(argv) + tool_revision = release_tool_revision() output_dir = args.output_dir.resolve() if output_dir.exists() and any(output_dir.iterdir()): raise ConversionError(f"Output directory must be empty: {output_dir}") @@ -448,7 +463,7 @@ def main(argv: list[str] | None = None) -> int: native_metadata, converter.map_old_key_to_new, ) - write_manifest(output_dir, mapping_sha256) + write_manifest(output_dir, mapping_sha256, tool_revision) assert_native_auto_loading(output_dir) print(f"Validated {EXPECTED_TENSOR_COUNT} tensors and wrote {output_dir}") return 0