5353_MISSING_ATTRIBUTE = object ()
5454
5555
56+ def _best_effort_package_commit (package_name : str ) -> str | None :
57+ """Best-effort immutable VCS commit for a pip-installed package.
58+
59+ When a dependency is installed from a git URL (``pip install
60+ git+https://...@<ref>``) -- the only way to get pre-release/unreleased
61+ HuggingFace model code such as Nemotron3Diarization's -- pip records the
62+ resolved commit SHA in the package's ``direct_url.json`` metadata. This
63+ lets golden-data provenance capture an immutable source identity instead
64+ of just a mutable, potentially ambiguous ``X.Y.Z.devN`` version string.
65+ Returns ``None`` (rather than raising) for a normal PyPI install, or if
66+ the metadata is missing/malformed, since this is diagnostic-only.
67+ """
68+ try :
69+ import json
70+ from importlib .metadata import distribution
71+
72+ direct_url = json .loads (
73+ distribution (package_name ).read_text ("direct_url.json" ) or "{}"
74+ )
75+ return direct_url .get ("vcs_info" , {}).get ("commit_id" )
76+ except Exception :
77+ return None
78+
79+
5680@contextlib .contextmanager
5781def _temporary_processor_max_pixels (processor : object , max_pixels : int | None ):
5882 """Temporarily override processor pixel limits and restore their exact state."""
@@ -1907,6 +1931,207 @@ def _hook(_module, _args, kwargs, output):
19071931 save_drafter_inputs (drafter_inputs_path_for_case (case ), arrays )
19081932
19091933
1934+ def _generate_diarization_offline (case : TestCase , json_path : Path , device : str ) -> None :
1935+ """Generate golden data for offline speaker diarization.
1936+
1937+ Diarization models emit continuous per-frame per-speaker sigmoid
1938+ probabilities, not a vocab logit vector, so the reference is stored via
1939+ ``save_diarization_golden()`` (full-precision ``probs`` array in a
1940+ companion ``.npz``) and compared with ``compare_diarization_golden()``
1941+ instead of the argmax-gated ``save_golden_ref()``/``compare_golden()``
1942+ used elsewhere in this file.
1943+
1944+ Two reference implementations are dispatched by ``case.model_type``:
1945+
1946+ * ``sortformer`` (NeMo Sortformer): built from a ``.nemo`` archive via
1947+ ``nemo_toolkit`` (not a mobius runtime dependency) --
1948+ ``frontend_encoder`` (mel -> embeddings) then ``forward_infer``
1949+ (embeddings -> per-frame speaker sigmoids). ``mel`` is generated
1950+ already channel-first ``[batch, feat, frames]`` -- NeMo's own input
1951+ contract -- matching the mobius ``DiarizationTask`` graph directly.
1952+ * otherwise (e.g. ``nemotron3_diarization``): a HuggingFace
1953+ ``AutoModelForAudioFrameClassification`` checkpoint. HuggingFace's
1954+ ``input_features`` are channel-last ``[batch, frames, feat]``; they
1955+ are transposed to channel-first before saving so the golden matches
1956+ the mobius task's input contract with no runtime transpose needed.
1957+ """
1958+ import numpy as np
1959+ import torch
1960+
1961+ from mobius ._testing .golden import save_diarization_golden
1962+
1963+ seed = int (case .generation_params .get ("seed" , 0 ))
1964+ torch .manual_seed (seed )
1965+
1966+ if case .model_type == "sortformer" :
1967+ import nemo # type: ignore[import-not-found]
1968+ from huggingface_hub import hf_hub_download
1969+ from nemo .collections .asr .models import ( # type: ignore[import-not-found]
1970+ SortformerEncLabelModel ,
1971+ )
1972+
1973+ filename = case .generation_params .get (
1974+ "nemo_filename" , "diar_streaming_sortformer_4spk-v2.1.nemo"
1975+ )
1976+ nemo_path = hf_hub_download (
1977+ repo_id = case .model_id , filename = filename , revision = case .revision
1978+ )
1979+ model = SortformerEncLabelModel .restore_from (nemo_path , map_location = device )
1980+ model .eval ()
1981+ model .streaming_mode = False # offline: full-context attention.
1982+
1983+ num_frames = int (case .generation_params .get ("num_frames" , 400 ))
1984+ feat_dim = int (model .cfg .encoder .feat_in )
1985+ mel = torch .randn (1 , feat_dim , num_frames , device = device )
1986+ mel_len = torch .tensor ([num_frames ], dtype = torch .long , device = device )
1987+ with torch .no_grad ():
1988+ emb_seq , emb_len = model .frontend_encoder (
1989+ processed_signal = mel , processed_signal_length = mel_len
1990+ )
1991+ preds = model .forward_infer (emb_seq , emb_len )
1992+
1993+ arrays = {
1994+ "mel" : mel .cpu ().numpy ().astype (np .float32 ),
1995+ "emb_seq" : emb_seq .cpu ().numpy ().astype (np .float32 ),
1996+ "emb_len" : emb_len .cpu ().numpy ().astype (np .int64 ),
1997+ "probs" : preds .cpu ().numpy ().astype (np .float32 ),
1998+ }
1999+ provenance = {
2000+ "model_id" : case .model_id ,
2001+ "revision" : case .revision ,
2002+ "nemo_version" : nemo .__version__ ,
2003+ "nemo_commit" : _best_effort_package_commit ("nemo_toolkit" ),
2004+ "seed" : seed ,
2005+ "feat_dim" : feat_dim ,
2006+ "num_frames" : num_frames ,
2007+ "num_speakers" : int (preds .shape [- 1 ]),
2008+ }
2009+ else :
2010+ import transformers
2011+ from transformers import AutoModelForAudioFrameClassification
2012+
2013+ model = AutoModelForAudioFrameClassification .from_pretrained (
2014+ case .model_id , revision = case .revision , dtype = torch .float32
2015+ )
2016+ model = model .to (device )
2017+ model .eval ()
2018+
2019+ mel_dim = model .config .audio_config .num_mel_bins
2020+ # Below chunk_length * subsampling_factor so HuggingFace's own
2021+ # offline forward does not internally re-chunk.
2022+ num_frames = int (case .generation_params .get ("num_frames" , 2000 ))
2023+ mel = torch .randn (1 , num_frames , mel_dim , device = device )
2024+ with torch .no_grad ():
2025+ out = model (input_features = mel )
2026+
2027+ arrays = {
2028+ "mel" : np .transpose (mel .cpu ().numpy (), (0 , 2 , 1 )).astype (np .float32 ),
2029+ "probs" : out .logits .sigmoid ().cpu ().numpy ().astype (np .float32 ),
2030+ }
2031+ provenance = {
2032+ "model_id" : case .model_id ,
2033+ "revision" : case .revision ,
2034+ "transformers_version" : transformers .__version__ ,
2035+ "transformers_commit" : _best_effort_package_commit ("transformers" ),
2036+ "torch_version" : torch .__version__ ,
2037+ "seed" : seed ,
2038+ "num_speakers" : int (model .config .head_config .num_speakers ),
2039+ "mel_dim" : mel_dim ,
2040+ }
2041+
2042+ save_diarization_golden (json_path , arrays = arrays , provenance = provenance )
2043+
2044+
2045+ def _generate_diarization_streaming (case : TestCase , json_path : Path , device : str ) -> None :
2046+ """Generate golden data for a multi-chunk streaming diarization session.
2047+
2048+ Runs the real HuggingFace ``Nemotron3DiarizationForAudioFrameClassification``
2049+ checkpoint (the ground-truth reference implementation) chunk by chunk,
2050+ threading the HuggingFace ``Nemotron3DiarizationSpeakerCache`` (Arrival-
2051+ Order Speaker Cache + FIFO queue) across chunks. Each chunk is a full
2052+ ``chunk_length + chunk_right_context`` window (matching the mobius
2053+ ``DiarizationStreamingTask`` ONNX graph's fixed input shape).
2054+
2055+ NOTE: the HF cache's ``.embeds``/``.probs``/``.fifo`` buffers are mutable
2056+ and updated in place via ``index_copy_`` across chunks -- ``.numpy()``
2057+ returns a *view*, so every per-chunk snapshot below is explicitly copied.
2058+ Without this, all per-chunk arrays would silently alias the final
2059+ chunk's values once ``np.savez`` serializes them (a real bug caught while
2060+ first authoring this golden -- see git history for
2061+ ``scripts/generate_nemotron3_diarization_golden.py``).
2062+ """
2063+ import numpy as np
2064+ import torch
2065+ import transformers
2066+ from transformers import AutoModelForAudioFrameClassification
2067+
2068+ from mobius ._testing .golden import save_diarization_golden
2069+
2070+ seed = int (case .generation_params .get ("seed" , 0 ))
2071+ num_chunks = int (case .generation_params .get ("num_chunks" , 3 ))
2072+
2073+ model = AutoModelForAudioFrameClassification .from_pretrained (
2074+ case .model_id , revision = case .revision , dtype = torch .float32
2075+ )
2076+ model = model .to (device )
2077+ model .eval ()
2078+
2079+ mel_dim = model .config .audio_config .num_mel_bins
2080+ chunk_length = model .config .chunk_length
2081+ chunk_right_context = model .config .chunk_right_context
2082+ subsampling = model .config .audio_config .subsampling_factor
2083+ raw_window = (chunk_length + chunk_right_context ) * subsampling
2084+
2085+ arrays : dict [str , np .ndarray ] = {"num_chunks" : np .array (num_chunks , dtype = np .int64 )}
2086+ cache = None
2087+ for i in range (num_chunks ):
2088+ torch .manual_seed (seed + 100 + i )
2089+ mel = torch .randn (1 , raw_window , mel_dim , device = device )
2090+ is_last = i == num_chunks - 1
2091+ lookahead = 0 if is_last else chunk_right_context
2092+ with torch .no_grad ():
2093+ out = model (
2094+ input_features = mel , speaker_cache = cache , num_lookahead_frames = lookahead
2095+ )
2096+ cache = out .speaker_cache
2097+ # HF's input_features are channel-last; transpose to the mobius
2098+ # DiarizationStreamingTask's channel-first contract.
2099+ arrays [f"chunk{ i } _mel" ] = (
2100+ np .transpose (mel .cpu ().numpy (), (0 , 2 , 1 )).astype (np .float32 ).copy ()
2101+ )
2102+ arrays [f"chunk{ i } _lookahead" ] = np .array (lookahead , dtype = np .int64 )
2103+ arrays [f"chunk{ i } _probs" ] = out .logits .sigmoid ().cpu ().numpy ().copy ()
2104+ arrays [f"chunk{ i } _cache_embeds" ] = cache .embeds .cpu ().numpy ().copy ()
2105+ arrays [f"chunk{ i } _cache_probs" ] = cache .probs .cpu ().numpy ().copy ()
2106+ arrays [f"chunk{ i } _fifo" ] = cache .fifo .cpu ().numpy ().copy ()
2107+ arrays [f"chunk{ i } _num_cache_frames" ] = np .array (cache .num_cache_frames , dtype = np .int64 )
2108+ arrays [f"chunk{ i } _num_fifo_frames" ] = np .array (cache .num_fifo_frames , dtype = np .int64 )
2109+ arrays [f"chunk{ i } _is_compressed" ] = np .array (cache .is_compressed , dtype = np .bool_ )
2110+
2111+ provenance = {
2112+ "model_id" : case .model_id ,
2113+ "revision" : case .revision ,
2114+ "transformers_version" : transformers .__version__ ,
2115+ "transformers_commit" : _best_effort_package_commit ("transformers" ),
2116+ "torch_version" : torch .__version__ ,
2117+ "seed" : seed ,
2118+ "num_speakers" : int (model .config .head_config .num_speakers ),
2119+ "mel_dim" : mel_dim ,
2120+ "chunk_length" : int (chunk_length ),
2121+ "chunk_right_context" : int (chunk_right_context ),
2122+ "subsampling_factor" : int (subsampling ),
2123+ "streaming_fifo_length" : int (model .config .streaming_config .fifo_length ),
2124+ "streaming_speaker_cache_length" : int (
2125+ model .config .streaming_config .speaker_cache_length
2126+ ),
2127+ "streaming_speaker_cache_update_period" : int (
2128+ model .config .streaming_config .speaker_cache_update_period
2129+ ),
2130+ "num_stream_chunks" : num_chunks ,
2131+ }
2132+ save_diarization_golden (json_path , arrays = arrays , provenance = provenance )
2133+
2134+
19102135# ---- Dispatcher ----
19112136
19122137# Map task_type strings to generator functions.
@@ -1930,6 +2155,8 @@ def _hook(_module, _args, kwargs, output):
19302155 "phi4mm-multimodal" : _generate_phi4mm_multimodal ,
19312156 "gemma4-assistant" : _generate_gemma4_assistant ,
19322157 "dflash-draft" : _generate_dflash_draft ,
2158+ "diarization" : _generate_diarization_offline ,
2159+ "diarization-streaming" : _generate_diarization_streaming ,
19332160}
19342161
19352162
0 commit comments