Coverage for tests/test_wrapper_vibevoice.py: 98%

318 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-09 04:47 +0000

1#!/usr/bin/env python3 

2 

3from __future__ import annotations 

4 

5import os 

6import sys 

7import gc 

8import base64 

9import inspect 

10import tempfile 

11import pytest 

12 

13import torch 

14 

15from typing import Any 

16 

17from types import ModuleType 

18 

19from unittest.mock import patch 

20from unittest.mock import MagicMock 

21 

22sys.path.append("wrapper") 

23sys.path.append("wrapper/vibevoice") 

24 

25 

26class DummySchedulerMixin: 

27 pass 

28 

29 

30class _FrozenDict(dict): 

31 """Dict-like config that also supports attribute access (mimics diffusers FrozenDict).""" 

32 def __getattr__(self, name: str) -> object: 

33 try: 

34 return self[name] 

35 except KeyError: 

36 raise AttributeError(name) 

37 

38 

39class DummyConfigMixin: 

40 """Mimics diffusers ConfigMixin: register_to_config stores __init__ kwargs in self.config.""" 

41 pass 

42 

43 

44class DummySchedulerOutput: 

45 pass 

46 

47 

48class DummyModelOutput: 

49 """Minimal stand-in for transformers.ModelOutput so @dataclass can resolve __mro__.""" 

50 pass 

51 

52 

53class DummyPretrainedConfig: 

54 """Minimal stand-in for transformers.PretrainedConfig.""" 

55 model_type = "" 

56 is_composition = False 

57 sub_configs: dict[str, object] = {} 

58 

59 def __init__(self, **kwargs: object) -> None: 

60 for k, v in kwargs.items(): 

61 setattr(self, k, v) 

62 

63 

64class DummyBaseModelOutputWithPast(DummyModelOutput): 

65 pass 

66 

67 

68class DummyPreTrainedModel(torch.nn.Module): 

69 """Minimal stand-in for transformers.PreTrainedModel.""" 

70 config_class = None 

71 base_model_prefix = "" 

72 

73 def __init__(self, config: object = None, *args: object, **kwargs: object) -> None: 

74 super().__init__() 

75 self.config = config 

76 

77 def _init_weights(self, module: object) -> None: 

78 pass 

79 

80 def post_init(self) -> None: 

81 pass 

82 

83 

84class DummyLlamaRMSNorm(torch.nn.Module): 

85 """Minimal stand-in for LlamaRMSNorm.""" 

86 def __init__(self, hidden_size: int, eps: float = 1e-6) -> None: 

87 super().__init__() 

88 

89 def forward(self, x: object) -> object: 

90 return x 

91 

92 

93def passthrough_register_to_config(func: Any) -> Any: 

94 """Mimics @register_to_config: wraps __init__ to store kwargs as self.config.""" 

95 sig = inspect.signature(func) 

96 

97 def wrapper( 

98 self: Any, 

99 *args: Any, 

100 **kwargs: Any, 

101 ) -> Any: 

102 bound = sig.bind(self, *args, **kwargs) 

103 bound.apply_defaults() 

104 cfg = {k: v for k, v in bound.arguments.items() if k != "self"} 

105 self.config = _FrozenDict(cfg) 

106 return func(self, *args, **kwargs) 

107 

108 return wrapper 

109 

110 

111diffusers_sched = ModuleType("diffusers.schedulers.scheduling_utils") 

112diffusers_sched.SchedulerMixin = DummySchedulerMixin # type: ignore[attr-defined] 

113diffusers_sched.SchedulerOutput = DummySchedulerOutput # type: ignore[attr-defined] 

114diffusers_sched.KarrasDiffusionSchedulers = MagicMock() # type: ignore[attr-defined] 

115 

116diffusers_conf = ModuleType("diffusers.configuration_utils") 

117diffusers_conf.ConfigMixin = DummyConfigMixin # type: ignore[attr-defined] 

118diffusers_conf.register_to_config = passthrough_register_to_config # type: ignore[attr-defined] 

119 

120# Build transformers mock modules with real classes where needed for inheritance 

121mock_transformers = MagicMock() 

122 

123mock_modeling_outputs = MagicMock() 

124mock_modeling_outputs.ModelOutput = DummyModelOutput 

125mock_modeling_outputs.BaseModelOutputWithPast = DummyBaseModelOutputWithPast 

126 

127mock_modeling_utils = MagicMock() 

128mock_modeling_utils.PreTrainedModel = DummyPreTrainedModel 

129 

130mock_llama_modeling = MagicMock() 

131mock_llama_modeling.LlamaRMSNorm = DummyLlamaRMSNorm 

132 

133mock_transformers_config = ModuleType("transformers.configuration_utils") 

134mock_transformers_config.PretrainedConfig = DummyPretrainedConfig # type: ignore[attr-defined] 

135 

136mock_modules = { 

137 "modeling_vibevoice_inference": MagicMock(), 

138 # "modeling_vibevoice": MagicMock(), 

139 "transformers": mock_transformers, 

140 "transformers.utils": MagicMock(), 

141 "transformers.modeling_utils": mock_modeling_utils, 

142 "transformers.modeling_outputs": mock_modeling_outputs, 

143 "transformers.generation": MagicMock(), 

144 "transformers.models": MagicMock(), 

145 "transformers.models.llama": MagicMock(), 

146 "transformers.models.llama.modeling_llama": mock_llama_modeling, 

147 "transformers.models.qwen2": MagicMock(), 

148 "transformers.models.qwen2.tokenization_qwen2": MagicMock(), 

149 "transformers.models.qwen2.tokenization_qwen2_fast": MagicMock(), 

150 "transformers.models.qwen2.configuration_qwen2": MagicMock(), 

151 "transformers.tokenization_utils_base": MagicMock(), 

152 "transformers.feature_extraction_utils": MagicMock(), 

153 "transformers.modeling_flash_attention_utils": MagicMock(), 

154 "transformers.configuration_utils": mock_transformers_config, 

155 "transformers.activations": MagicMock(), 

156 "diffusers": MagicMock(), 

157 "diffusers.schedulers": ModuleType("diffusers.schedulers"), 

158 "diffusers.schedulers.scheduling_utils": diffusers_sched, 

159 "diffusers.configuration_utils": diffusers_conf, 

160 "diffusers.utils": MagicMock(), 

161 "diffusers.utils.torch_utils": MagicMock(), 

162} 

163 

164 

165with patch.dict(sys.modules, mock_modules): 

166 from vibevoice.wrapper_vibevoice import VibeVoiceGeneration 

167 from vibevoice.wrapper_vibevoice import VoiceMapper 

168 

169 from configuration_vibevoice import VibeVoiceConfig 

170 from configuration_vibevoice import VibeVoiceSemanticTokenizerConfig 

171 from modeling_vibevoice_inference import VibeVoiceForConditionalGenerationInference 

172 from audio_streamer import AudioStreamer 

173 

174 # from modeling_vibevoice import VibeVoicePreTrainedModel 

175 from modeling_vibevoice import VibeVoiceCausalLMOutputWithPast 

176 from modeling_vibevoice import SpeechConnector 

177 from modeling_vibevoice import VibeVoiceGenerationOutput 

178 from modeling_vibevoice import VibeVoicePreTrainedModel 

179 from modular_vibevoice_diffusion_head import RMSNorm 

180 from modular_vibevoice_tokenizer import NormConvTranspose1d 

181 from modular_vibevoice_tokenizer import VibeVoiceTokenizerStreamingCache 

182 from modular_vibevoice_tokenizer import VibeVoiceSemanticTokenizerModel 

183 from schedule.timestep_sampler import UniformSampler, LogitNormalSampler 

184 from schedule.dpm_solver import DPMSolverMultistepScheduler 

185 from schedule.dpm_solver import rescale_zero_terminal_snr 

186 from schedule.dpm_solver import betas_for_alpha_bar 

187 from modular_vibevoice_text_tokenizer import VibeVoiceTextTokenizer 

188 

189 

190@pytest.mark.asyncio 

191async def test_vibevoice() -> None: 

192 model = VibeVoiceGeneration() 

193 assert model is not None 

194 assert model.model_name == "vibevoice" 

195 assert model.status == "initializing" 

196 

197 with pytest.raises(AttributeError): # TODO 

198 model.init() 

199 assert model.status == "failed" 

200 

201 health = model.get_health() 

202 assert health is not None 

203 assert len(health) > 1 

204 timestamps = model.get_timestamps() 

205 assert timestamps is not None 

206 assert len(timestamps) >= 1 

207 

208 with pytest.raises(ValueError): 

209 await model.get_rest_args(None) 

210 with pytest.raises(ValueError): 

211 await model.get_rest_args({}) 

212 await model.get_rest_args({ 

213 "text": "Test text" 

214 }) 

215 

216 with pytest.raises(ValueError, match="Model not initialized"): 

217 await model.warmup() 

218 

219 with pytest.raises(ValueError, match="Model not initialized"): 

220 await model.generate( 

221 text="Test text", 

222 output_type="audio_path") 

223 

224 del model 

225 gc.collect() 

226 

227 

228def test_vibevoice_libs() -> None: 

229 assert VibeVoiceCausalLMOutputWithPast is not None 

230 assert VibeVoiceGenerationOutput is not None 

231 assert SpeechConnector is not None 

232 assert VibeVoicePreTrainedModel is not None 

233 assert RMSNorm is not None 

234 

235 

236def test_tokenizer() -> None: 

237 assert NormConvTranspose1d is not None 

238 assert VibeVoiceTokenizerStreamingCache is not None 

239 tokenizer = VibeVoiceSemanticTokenizerModel(VibeVoiceSemanticTokenizerConfig()) 

240 assert tokenizer is not None 

241 

242 

243def test_text_tokenizer() -> None: 

244 tokenizer = VibeVoiceTextTokenizer( 

245 vocab_file="missing_vocab_file", 

246 merges_file="missing_merges_file") 

247 assert tokenizer is not None 

248 

249 

250def test_model_inference() -> None: 

251 # Configuration 

252 vibevoice_config = VibeVoiceConfig() 

253 assert vibevoice_config is not None 

254 

255 # Inference 

256 inference = VibeVoiceForConditionalGenerationInference(vibevoice_config) 

257 assert inference is not None 

258 assert inference.forward() is not None 

259 assert inference.generate() is not None 

260 

261 

262def test_audio_streamer() -> None: 

263 audio_streamer = AudioStreamer(batch_size=8) 

264 assert audio_streamer is not None 

265 audio_streamer.put( 

266 audio_chunks=MagicMock(), 

267 sample_indices=MagicMock(), 

268 ) 

269 audio_streamer.end(sample_indices=MagicMock()) 

270 

271 

272def test_voice_mapper() -> None: 

273 voice_mapper = VoiceMapper() 

274 voice_mapper.setup_voice_presets() 

275 

276 # voice_presets is a dict (may be empty or populated depending on environment) 

277 assert isinstance(voice_mapper.voice_presets, dict) 

278 

279 if not voice_mapper.voice_presets: 

280 # No voices available: get_voice_path raises ValueError 

281 with pytest.raises(ValueError, match="No voice presets available"): 

282 voice_mapper.get_voice_path("any_speaker") 

283 else: 

284 # Voices available: get_voice_path returns a path for any speaker name 

285 path = voice_mapper.get_voice_path("any_speaker") 

286 assert isinstance(path, str) 

287 

288 

289@pytest.mark.asyncio 

290async def test_vibevoice_get_rest_args_voice() -> None: 

291 model = VibeVoiceGeneration() 

292 

293 # Custom voice parameter is returned in args 

294 result = await model.get_rest_args({"text": "Hello world", "voice": "custom_voice"}) 

295 assert result["args"]["voice"] == "custom_voice" 

296 

297 # Default voice returned when voice not specified 

298 result_default = await model.get_rest_args({"text": "Hello world"}) 

299 assert "voice" in result_default["args"] 

300 assert result_default["args"]["voice"] == "af_heart" 

301 

302 

303@pytest.mark.asyncio 

304async def test_vibevoice_get_rest_args_voice_sample() -> None: 

305 """get_rest_args forwards voice_sample when present and omits it when absent.""" 

306 model = VibeVoiceGeneration() 

307 

308 with open("tests/data/sample.wav", "rb") as f: 

309 wav_bytes = f.read() 

310 dummy_audio = base64.b64encode(wav_bytes).decode() 

311 

312 # voice_sample present -> included in args 

313 result = await model.get_rest_args({ 

314 "text": "Hello world", 

315 "voice_sample": dummy_audio, 

316 }) 

317 assert result["args"].get("voice_sample") == dummy_audio 

318 

319 # voice_sample absent -> not included in args 

320 result_no_sample = await model.get_rest_args({"text": "Hello world"}) 

321 assert "voice_sample" not in result_no_sample["args"] 

322 

323 

324def test_decode_voice_sample_to_tmp_file() -> None: 

325 """_decode_voice_sample_to_tmp_file writes the decoded bytes to a temp WAV file.""" 

326 model = VibeVoiceGeneration() 

327 

328 with open("tests/data/sample.wav", "rb") as f: 

329 audio_content = f.read() 

330 voice_sample_b64 = base64.b64encode(audio_content).decode() 

331 

332 tmp_path = model._decode_voice_sample_to_tmp_file(voice_sample_b64) 

333 try: 

334 assert os.path.exists(tmp_path), "Temp file must be created" 

335 assert tmp_path.endswith(".wav"), "Temp file must have .wav extension" 

336 with open(tmp_path, "rb") as f: 

337 written = f.read() 

338 assert written == audio_content, "Written bytes must match original audio content" 

339 finally: 

340 if os.path.exists(tmp_path): 

341 os.unlink(tmp_path) 

342 

343 

344@pytest.mark.asyncio 

345async def test_vibevoice_voice_sample_debug_file_saved() -> None: 

346 """When voice_sample + job_id are provided, the decoded audio is persisted to 

347 /tmp/{job_id}_voice_sample.wav before processing begins.""" 

348 from unittest.mock import patch, MagicMock 

349 

350 model = VibeVoiceGeneration() 

351 

352 with open("tests/data/sample.wav", "rb") as f: 

353 wav_bytes = f.read() 

354 voice_sample_b64 = base64.b64encode(wav_bytes).decode() 

355 

356 job_id = "vibevoice_debug_test_001" 

357 debug_path = f"/tmp/{os.path.basename(job_id)}_voice_sample.wav" 

358 

359 if os.path.exists(debug_path): 

360 os.unlink(debug_path) 

361 

362 # Bypass model-init guard; leave processor=None so generate() raises *after* 

363 # it has already written the debug file. 

364 model.voice_mapper = MagicMock() 

365 model.processor = None 

366 

367 with patch.object(model, "_assert_model_init"): 

368 with pytest.raises(ValueError, match="Processor not initialized"): 

369 await model.generate(text="Hello", voice_sample=voice_sample_b64, job_id=job_id) 

370 

371 try: 

372 assert os.path.exists(debug_path), "Debug voice sample file must be created" 

373 with open(debug_path, "rb") as f: 

374 assert f.read() == wav_bytes, "Debug file content must match original audio" 

375 finally: 

376 if os.path.exists(debug_path): 

377 os.unlink(debug_path) 

378 

379 

380@pytest.mark.asyncio 

381async def test_vibevoice_voice_sample_no_job_id_uses_tmp_file() -> None: 

382 """When voice_sample is provided but job_id is None, a temporary file is used 

383 (and will be cleaned up), not a named debug file.""" 

384 from unittest.mock import patch, MagicMock 

385 

386 model = VibeVoiceGeneration() 

387 

388 with open("tests/data/sample.wav", "rb") as f: 

389 wav_bytes = f.read() 

390 voice_sample_b64 = base64.b64encode(wav_bytes).decode() 

391 

392 recorded: list[str] = [] 

393 original_decode = model._decode_voice_sample_to_tmp_file 

394 

395 def capturing_decode(vs: str) -> str: 

396 path = original_decode(vs) 

397 recorded.append(path) 

398 return path 

399 

400 model.voice_mapper = MagicMock() 

401 model.processor = None 

402 

403 with patch.object(model, "_assert_model_init"): 

404 with patch.object(model, "_decode_voice_sample_to_tmp_file", side_effect=capturing_decode): 

405 with pytest.raises(ValueError, match="Processor not initialized"): 

406 # job_id=None → should use temp file, not a named debug path 

407 await model.generate(text="Hello", voice_sample=voice_sample_b64, job_id=None) 

408 

409 assert len(recorded) == 1, "_decode_voice_sample_to_tmp_file must be called once" 

410 # Temp file should have been cleaned up by the finally block inside generate() 

411 assert not os.path.exists(recorded[0]), "Temp voice file must be deleted after generate()" 

412 

413 

414def test_cleanup_tmp_voice_file() -> None: 

415 """_cleanup_tmp_voice_file removes the file and handles None / missing paths gracefully.""" 

416 model = VibeVoiceGeneration() 

417 

418 # None input is a no-op (must not raise) 

419 model._cleanup_tmp_voice_file(None) 

420 

421 # Existing file is deleted 

422 with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: 

423 tmp_path = f.name 

424 assert os.path.exists(tmp_path) 

425 model._cleanup_tmp_voice_file(tmp_path) 

426 assert not os.path.exists(tmp_path), "File must be deleted by _cleanup_tmp_voice_file" 

427 

428 # Already-deleted path must not raise 

429 model._cleanup_tmp_voice_file(tmp_path) 

430 

431 

432def test_timestep_samplers() -> None: 

433 uniform = UniformSampler(timesteps=100) 

434 result_u = uniform.sample(batch_size=4, device=torch.device('cpu')) 

435 assert result_u is not None 

436 

437 logit = LogitNormalSampler(timesteps=100) 

438 result_l = logit.sample(batch_size=4, device=torch.device('cpu')) 

439 assert result_l is not None 

440 

441 

442def test_dpm_solver_scheduler() -> None: 

443 scheduler = DPMSolverMultistepScheduler(num_train_timesteps=100) 

444 assert scheduler is not None 

445 assert hasattr(scheduler, "betas") 

446 assert hasattr(scheduler, "alphas_cumprod") 

447 

448 # Test other beta schedules to cover more branches 

449 for schedule in ["scaled_linear", "squaredcos_cap_v2", "cosine"]: 

450 s = DPMSolverMultistepScheduler(num_train_timesteps=50, beta_schedule=schedule) 

451 assert hasattr(s, "betas") 

452 

453 # Unknown schedule raises NotImplementedError 

454 with pytest.raises(NotImplementedError): 

455 DPMSolverMultistepScheduler(beta_schedule="unknown_schedule") 

456 

457 # Test betas_for_alpha_bar directly with different alpha_transform_types 

458 for alpha_type in ["cosine", "exp", "cauchy", "laplace"]: 

459 betas = betas_for_alpha_bar(10, alpha_transform_type=alpha_type) 

460 assert betas is not None 

461 

462 with pytest.raises(ValueError): 

463 betas_for_alpha_bar(10, alpha_transform_type="unknown") 

464 

465 

466def test_dpm_solver_set_timesteps() -> None: 

467 

468 # set_timesteps with different timestep_spacing values 

469 for spacing in ["linspace", "leading", "trailing"]: 

470 s = DPMSolverMultistepScheduler(num_train_timesteps=100, timestep_spacing=spacing) 

471 s.set_timesteps(10) 

472 assert s.num_inference_steps == 10 

473 assert len(s.timesteps) == 10 

474 

475 # set_timesteps with use_karras_sigmas covers _convert_to_karras + _sigma_to_t 

476 s_karras = DPMSolverMultistepScheduler(num_train_timesteps=100, use_karras_sigmas=True) 

477 s_karras.set_timesteps(10) 

478 assert s_karras.num_inference_steps == 10 

479 

480 # set_timesteps with use_lu_lambdas covers _convert_to_lu + _sigma_to_t 

481 s_lu = DPMSolverMultistepScheduler(num_train_timesteps=100, use_lu_lambdas=True) 

482 s_lu.set_timesteps(10) 

483 assert s_lu.num_inference_steps == 10 

484 

485 # set_timesteps with custom timesteps list 

486 s_custom = DPMSolverMultistepScheduler(num_train_timesteps=100) 

487 s_custom.set_timesteps(timesteps=[80, 60, 40, 20]) 

488 assert s_custom.num_inference_steps == 4 

489 

490 # Error: neither argument provided 

491 with pytest.raises(ValueError, match="Must pass exactly one of"): 

492 s_custom.set_timesteps() 

493 

494 # Error: both provided 

495 with pytest.raises(ValueError, match="Can only pass one"): 

496 s_custom.set_timesteps(num_inference_steps=10, timesteps=[80, 60]) 

497 

498 # Error: custom timesteps with karras_sigmas 

499 with pytest.raises(ValueError, match="use_karras_sigmas"): 

500 s_karras.set_timesteps(timesteps=[80, 60]) 

501 

502 # __len__, set_begin_index, step_index and begin_index properties 

503 s = DPMSolverMultistepScheduler(num_train_timesteps=200) 

504 assert len(s) == 200 

505 s.set_begin_index(5) 

506 assert s.begin_index == 5 

507 assert s.step_index is None # before any step 

508 

509 # rescale_zero_terminal_snr 

510 betas = torch.linspace(0.0001, 0.02, 100) 

511 rescaled = rescale_zero_terminal_snr(betas) 

512 assert rescaled is not None 

513 assert rescaled.shape == betas.shape 

514 

515 # add_noise and get_velocity 

516 s.set_timesteps(10) 

517 original = torch.randn(2, 4) 

518 noise = torch.randn(2, 4) 

519 timesteps = torch.IntTensor([50, 80]) 

520 noisy = s.add_noise(original, noise, timesteps) 

521 assert noisy.shape == original.shape 

522 velocity = s.get_velocity(original, noise, timesteps) 

523 assert velocity.shape == original.shape 

524 

525 # _sigma_to_alpha_sigma_t and _sigma_to_t are exercised by set_timesteps 

526 # Make sure sigmas attribute exists after set_timesteps 

527 assert hasattr(s, "sigmas") 

528 assert s.sigmas is not None 

529 

530 

531def test_vibevoice_model_methods() -> None: 

532 """Test _assert_model_init, init_parallelism, init_model_parallelism, model_compile.""" 

533 model = VibeVoiceGeneration() 

534 

535 # _assert_model_init raises before model is initialized (status != "ok") 

536 with pytest.raises(ValueError, match="Model not initialized"): 

537 model._assert_model_init() 

538 

539 # init_parallelism runs without error (no CUDA in test env, uses CPU path) 

540 model.init_parallelism() 

541 assert model.rank == 0 

542 assert model.world_size == 1 

543 

544 # init_model_parallelism: world_size=1, just logs a warning if > 1 

545 model.init_model_parallelism() 

546 

547 # model_compile: short-circuits when torch_compile=False 

548 model.torch_compile = False 

549 model.model_compile() # should return immediately without error 

550 

551 del model