Coverage for wrapper/vibevoice/vibevoice_processor.py: 18%

304 statements  

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

1# mypy: ignore-errors 

2# Copy from https://github.com/microsoft/VibeVoice/blob/main/vibevoice/processor/vibevoice_processor.py 

3 

4import os 

5import json 

6import re 

7import math 

8 

9from typing import List 

10from typing import Optional 

11from typing import Union 

12from typing import Dict 

13from typing import Any 

14from typing import Tuple 

15from typing import cast 

16 

17import numpy as np 

18import torch 

19 

20from transformers.tokenization_utils_base import BatchEncoding 

21from transformers.tokenization_utils_base import PaddingStrategy 

22from transformers.tokenization_utils_base import PreTokenizedInput 

23from transformers.tokenization_utils_base import TextInput 

24from transformers.tokenization_utils_base import TruncationStrategy 

25from transformers.utils import TensorType 

26from transformers.utils import logging 

27 

28from vibevoice_audio_processor import AudioNormalizer 

29from vibevoice_audio_processor import VibeVoiceTokenizerProcessor 

30 

31from modular_vibevoice_text_tokenizer import VibeVoiceTextTokenizerFast 

32 

33logger = logging.get_logger(__name__) 

34 

35 

36class VibeVoiceProcessor: 

37 r""" 

38 Constructs a VibeVoice processor which wraps a VibeVoice tokenizer and audio processor into a single processor. 

39 [`VibeVoiceProcessor`] offers all the functionalities of [`VibeVoiceTokenizer`] and [`VibeVoiceTokenizerProcessor`]. 

40 See the [`~VibeVoiceProcessor.__call__`] and [`~VibeVoiceProcessor.decode`] for more information. 

41 Args: 

42 tokenizer (`VibeVoiceTextTokenizer` or `VibeVoiceTextTokenizerFast`): 

43 The tokenizer for text processing. 

44 audio_processor (`VibeVoiceTokenizerProcessor`): 

45 The audio processor for speech processing. 

46 speech_tok_compress_ratio (`int`, *optional*, defaults to 3200): 

47 The compression ratio for speech tokenization. 

48 db_normalize (`bool`, *optional*, defaults to True): 

49 Whether to apply decibel normalization to audio inputs. 

50 """ 

51 

52 def __init__( 

53 self, 

54 tokenizer: Optional[Any] = None, 

55 audio_processor: Optional[Any] = None, 

56 speech_tok_compress_ratio: int = 3200, 

57 db_normalize: bool = True, 

58 **kwargs: Any 

59 ) -> None: 

60 self.tokenizer = tokenizer 

61 self.audio_processor = audio_processor 

62 self.speech_tok_compress_ratio = speech_tok_compress_ratio 

63 self.db_normalize = db_normalize 

64 self.audio_normalizer = AudioNormalizer() if db_normalize else None 

65 self.system_prompt = ( 

66 " Transform the text provided by various speakers into speech output, utilizing the " 

67 "distinct voice of each respective speaker.\n" 

68 ) 

69 

70 @classmethod 

71 def from_pretrained( 

72 cls, 

73 pretrained_model_name_or_path: Union[str, os.PathLike], 

74 **kwargs: Any 

75 ) -> "VibeVoiceProcessor": 

76 """ 

77 Instantiate a VibeVoiceProcessor from a pretrained VibeVoice processor. 

78 Args: 

79 pretrained_model_name_or_path (`str` or `os.PathLike`): 

80 This can be either: 

81 - a string, the *model id* of a pretrained model 

82 - a path to a *directory* containing processor config 

83 Returns: 

84 [`VibeVoiceProcessor`]: The processor object instantiated from pretrained model. 

85 """ 

86 # Load processor configuration 

87 config_path = os.path.join(pretrained_model_name_or_path, "preprocessor_config.json") 

88 if os.path.exists(config_path): 

89 with open(config_path, 'r') as f: 

90 config = json.load(f) 

91 else: 

92 logger.warning(f"No preprocessor_config.json found at {pretrained_model_name_or_path}, using defaults") 

93 config = { 

94 "speech_tok_compress_ratio": 3200, 

95 "db_normalize": True, 

96 } 

97 

98 # Extract main processor parameters 

99 speech_tok_compress_ratio = config.get("speech_tok_compress_ratio", 3200) 

100 db_normalize = config.get("db_normalize", True) 

101 

102 # Load tokenizer - try from model path first, then fallback to Qwen 

103 language_model_pretrained_name = ( 

104 config.get("language_model_pretrained_name", None) 

105 or kwargs.pop("language_model_pretrained_name", "Qwen/Qwen2.5-1.5B") 

106 ) 

107 logger.info(f"Loading tokenizer from {language_model_pretrained_name}") 

108 if 'qwen' in language_model_pretrained_name.lower(): 

109 tokenizer = VibeVoiceTextTokenizerFast.from_pretrained( 

110 language_model_pretrained_name, 

111 **kwargs 

112 ) 

113 else: 

114 raise ValueError( 

115 f"Unsupported tokenizer type for {language_model_pretrained_name}. " 

116 "Supported types: Qwen, Llama, Gemma.") 

117 

118 # Load audio processor 

119 if "audio_processor" in config: 

120 # Create audio processor from config 

121 audio_config = config["audio_processor"] 

122 audio_processor = VibeVoiceTokenizerProcessor( 

123 sampling_rate=audio_config.get("sampling_rate", 24000), 

124 normalize_audio=audio_config.get("normalize_audio", True), 

125 target_dB_FS=audio_config.get("target_dB_FS", -25), 

126 eps=audio_config.get("eps", 1e-6), 

127 ) 

128 else: 

129 # Create default audio processor 

130 audio_processor = VibeVoiceTokenizerProcessor() 

131 

132 # Create and return the processor 

133 return cls( 

134 tokenizer=tokenizer, 

135 audio_processor=audio_processor, 

136 speech_tok_compress_ratio=speech_tok_compress_ratio, 

137 db_normalize=db_normalize, 

138 ) 

139 

140 def save_pretrained( 

141 self, 

142 save_directory: Union[str, os.PathLike], 

143 **kwargs: Dict[str, Any] 

144 ) -> None: 

145 """ 

146 Save a processor to a directory, so that it can be re-loaded using the 

147 [`~VibeVoiceProcessor.from_pretrained`] class method. 

148 Args: 

149 save_directory (`str` or `os.PathLike`): 

150 Directory where the processor will be saved. 

151 """ 

152 import os 

153 import json 

154 

155 os.makedirs(save_directory, exist_ok=True) 

156 

157 # Save processor configuration 

158 processor_config = { 

159 "processor_class": "VibeVoiceProcessor", 

160 "speech_tok_compress_ratio": self.speech_tok_compress_ratio, 

161 "db_normalize": self.db_normalize, 

162 "audio_processor": { 

163 "feature_extractor_type": "VibeVoiceTokenizerProcessor", 

164 "sampling_rate": getattr(self.audio_processor, 'sampling_rate', 24000), 

165 "normalize_audio": getattr(self.audio_processor, 'normalize_audio', True), 

166 "target_dB_FS": getattr(self.audio_processor, 'target_dB_FS', -25), 

167 "eps": getattr(self.audio_processor, 'eps', 1e-6), 

168 } 

169 } 

170 

171 config_path = os.path.join(save_directory, "preprocessor_config.json") 

172 with open(config_path, 'w') as f: 

173 json.dump(processor_config, f, indent=2) 

174 

175 logger.info(f"Processor configuration saved in {config_path}") 

176 

177 def __call__( 

178 self, 

179 text: Optional[Union[str, List[Any], TextInput, PreTokenizedInput]] = None, 

180 voice_samples: Optional[Union[List[Union[str, np.ndarray]], List[List[Union[str, np.ndarray]]]]] = None, 

181 padding: Union[bool, str, PaddingStrategy] = True, 

182 truncation: Union[bool, str, TruncationStrategy] = False, 

183 max_length: Optional[int] = None, 

184 return_tensors: Optional[Union[str, TensorType]] = None, 

185 return_attention_mask: bool = True, 

186 **kwargs: Dict[str, Any] 

187 ) -> BatchEncoding: 

188 """ 

189 Main method to process one or more podcast scripts with optional voice samples. 

190 Args: 

191 text (`str`, `List[str]`): 

192 The input text(s) to process. Can be: 

193 - A single script string 

194 - A list of script strings for batch processing 

195 - A path to a .json or .txt file 

196 - A list of paths 

197 voice_samples (`List[Union[str, np.ndarray]]`, `List[List[Union[str, np.ndarray]]]`, *optional*): 

198 Voice samples for each script. Can be: 

199 - A list of samples for a single script 

200 - A list of lists for batch processing 

201 padding (`bool`, `str` or `PaddingStrategy`, defaults to `True`): 

202 Whether to pad sequences to the same length 

203 truncation (`bool`, `str` or `TruncationStrategy`, defaults to `False`): 

204 Whether to truncate sequences 

205 max_length (`int`, *optional*): 

206 Maximum length of the returned sequences 

207 return_tensors (`str` or `TensorType`, *optional*): 

208 If set, will return tensors of a particular framework 

209 return_attention_mask (`bool`, defaults to `True`): 

210 Whether to return the attention mask 

211 Returns: 

212 `BatchEncoding`: A BatchEncoding with the following fields: 

213 - **input_ids** -- List of token id sequences or tensor 

214 - **attention_mask** -- List of attention masks or tensor 

215 - **speech_tensors** -- Padded speech inputs (if voice_samples provided) 

216 - **speech_masks** -- Speech masks (if voice_samples provided) 

217 - **speech_input_mask** -- Boolean masks indicating speech token positions 

218 """ 

219 # Handle single vs batch input 

220 texts: List[Any] = [] 

221 is_batched = False 

222 if isinstance(text, str) or (isinstance(text, list) and len(text) > 0 and not isinstance(text[0], str)): 

223 # Single input 

224 texts = [text] 

225 is_batched = False 

226 else: 

227 # Batch input 

228 texts = cast(List[Any], text) 

229 is_batched = True 

230 

231 # Handle voice samples 

232 voice_samples_list: List[Optional[List[Union[str, np.ndarray]]]] = [] 

233 if voice_samples is not None: 

234 if not is_batched or (isinstance(voice_samples[0], (str, np.ndarray))): 

235 # Single set of voice samples 

236 voice_samples_list = [cast(List[Union[str, np.ndarray]], voice_samples)] 

237 else: 

238 # Batch of voice samples 

239 voice_samples_list = cast(List[Optional[List[Union[str, np.ndarray]]]], voice_samples) 

240 else: 

241 voice_samples_list = [None] * len(texts) 

242 

243 # Process each input 

244 all_encodings = [] 

245 for text_input, voice_input in zip(texts, voice_samples_list): 

246 encoding = self._process_single(text_input, voice_input) 

247 all_encodings.append(encoding) 

248 

249 # Combine batch 

250 batch_encoding = self._batch_encode( 

251 all_encodings, 

252 padding=padding, 

253 truncation=truncation, 

254 max_length=max_length, 

255 return_tensors=return_tensors, 

256 return_attention_mask=return_attention_mask, 

257 ) 

258 

259 return batch_encoding 

260 

261 def _process_single( 

262 self, 

263 text: Union[str, TextInput], 

264 voice_samples: Optional[List[Union[str, np.ndarray]]] = None, 

265 ) -> Dict[str, Any]: 

266 """Process a single podcast script.""" 

267 # Determine if text is a file path or direct script 

268 script = None 

269 if isinstance(text, str): 

270 # Check if it's a file path 

271 if text.endswith('.json') and os.path.exists(text): 

272 script = self._convert_json_to_script(text) 

273 elif text.endswith('.txt') and os.path.exists(text): 

274 script = self._convert_text_to_script(text) 

275 else: 

276 # Assume it's the script content directly 

277 script = text 

278 

279 if script is None: 

280 raise ValueError(f"Could not process input text: {text}") 

281 

282 # Parse the script 

283 parsed_lines = self._parse_script(script) 

284 all_speakers = list(set(speaker_id for speaker_id, _ in parsed_lines)) 

285 

286 # Create system prompt 

287 # system_tokens = self.tokenizer.encode(self.system_prompt, add_special_tokens=False) 

288 if not self.tokenizer: 

289 raise ValueError("Tokenizer is not initialized.") 

290 system_tokens = self.tokenizer.encode(self.system_prompt) 

291 

292 # Process voice samples if provided 

293 if voice_samples: 

294 voice_tokens, voice_speech_inputs, voice_speech_masks = self._create_voice_prompt( 

295 voice_samples[:len(all_speakers)]) 

296 else: 

297 voice_tokens, voice_speech_inputs, voice_speech_masks = [], [], [] 

298 

299 # Build full token sequence 

300 full_tokens = system_tokens + voice_tokens 

301 speech_input_mask = [False] * len(system_tokens) + voice_speech_masks 

302 

303 # Add text input section 

304 full_tokens += self.tokenizer.encode(' Text input:\n', add_special_tokens=False) 

305 speech_input_mask += [False] * len(self.tokenizer.encode(' Text input:\n', add_special_tokens=False)) 

306 

307 for speaker_id, speaker_text in parsed_lines: 

308 speaker_text_tokens = self.tokenizer.encode( 

309 f" Speaker {speaker_id}:{speaker_text}\n", 

310 add_special_tokens=False) 

311 full_tokens += speaker_text_tokens 

312 speech_input_mask += [False] * len(speaker_text_tokens) 

313 

314 # Add speech output section 

315 full_tokens += ( 

316 self.tokenizer.encode(' Speech output:\n', add_special_tokens=False) 

317 + [self.tokenizer.speech_start_id] 

318 ) 

319 speech_input_mask += [False] * (len(self.tokenizer.encode(' Speech output:\n', add_special_tokens=False)) + 1) 

320 

321 return { 

322 "input_ids": full_tokens, 

323 "speech_inputs": voice_speech_inputs if voice_speech_inputs else None, 

324 "speech_input_mask": speech_input_mask, 

325 "parsed_script": parsed_lines, 

326 "all_speakers": all_speakers, 

327 } 

328 

329 def _batch_encode( 

330 self, 

331 encodings: List[Dict[str, Any]], 

332 padding: Union[bool, str, PaddingStrategy] = True, 

333 truncation: Union[bool, str, TruncationStrategy] = False, 

334 max_length: Optional[int] = None, 

335 return_tensors: Optional[Union[str, TensorType]] = None, 

336 return_attention_mask: bool = True, 

337 ) -> BatchEncoding: 

338 """Combine multiple encodings into a batch with padding.""" 

339 # Extract input_ids and create attention_mask 

340 input_ids_list = [enc["input_ids"] for enc in encodings] 

341 speech_input_masks_list = [enc["speech_input_mask"] for enc in encodings] 

342 

343 # Determine padding strategy 

344 if isinstance(padding, bool): 

345 padding_strategy = PaddingStrategy.LONGEST if padding else PaddingStrategy.DO_NOT_PAD 

346 elif isinstance(padding, str): 

347 padding_strategy = PaddingStrategy(padding) 

348 else: 

349 padding_strategy = padding 

350 

351 if not self.tokenizer: 

352 raise ValueError("Tokenizer is not initialized.") 

353 

354 # Apply padding to input_ids 

355 if padding_strategy != PaddingStrategy.DO_NOT_PAD: 

356 if padding_strategy == PaddingStrategy.LONGEST: 

357 max_len = max(len(ids) for ids in input_ids_list) 

358 elif padding_strategy == PaddingStrategy.MAX_LENGTH and max_length is not None: 

359 max_len = max_length 

360 else: 

361 max_len = max(len(ids) for ids in input_ids_list) 

362 

363 # Pad sequences 

364 padded_input_ids = [] 

365 padded_attention_masks: List[List[int]] = [] 

366 padded_speech_input_masks = [] 

367 

368 for input_ids, speech_mask in zip(input_ids_list, speech_input_masks_list): 

369 # Truncate if needed 

370 if truncation and len(input_ids) > max_len: 

371 input_ids = input_ids[:max_len] 

372 speech_mask = speech_mask[:max_len] 

373 

374 # Pad 

375 padding_length = max_len - len(input_ids) 

376 # padded_ids = [self.tokenizer.pad_token_id] * padding_length + input_ids 

377 padded_ids = [self.tokenizer.pad_id] * padding_length + input_ids 

378 attention_mask = [0] * padding_length + [1] * len(input_ids) 

379 padded_speech_mask = [False] * padding_length + speech_mask 

380 

381 padded_input_ids.append(padded_ids) 

382 padded_attention_masks.append(attention_mask) 

383 padded_speech_input_masks.append(padded_speech_mask) 

384 

385 attention_masks: Optional[List[List[int]]] = padded_attention_masks 

386 input_ids_list = padded_input_ids 

387 speech_input_masks_list = padded_speech_input_masks 

388 else: 

389 # No padding, just create attention masks 

390 attention_masks = [[1] * len(ids) for ids in input_ids_list] if return_attention_mask else None 

391 

392 # Process speech inputs 

393 all_speech_inputs = [] 

394 has_speech = False 

395 for enc in encodings: 

396 if enc["speech_inputs"] is not None: 

397 all_speech_inputs.extend(enc["speech_inputs"]) 

398 has_speech = True 

399 

400 # Prepare batch encoding 

401 batch_encoding = BatchEncoding() 

402 

403 # Handle tensor conversion 

404 if return_tensors is not None: 

405 batch_encoding["input_ids"] = torch.tensor(input_ids_list, dtype=torch.long) 

406 if return_attention_mask and attention_masks is not None: 

407 batch_encoding["attention_mask"] = torch.tensor(attention_masks, dtype=torch.long) 

408 batch_encoding["speech_input_mask"] = torch.tensor(speech_input_masks_list, dtype=torch.bool) 

409 else: 

410 batch_encoding["input_ids"] = input_ids_list 

411 if return_attention_mask and attention_masks is not None: 

412 batch_encoding["attention_mask"] = attention_masks 

413 batch_encoding["speech_input_mask"] = speech_input_masks_list 

414 

415 # Process speech tensors if present 

416 if has_speech: 

417 speech_dict = self.prepare_speech_inputs( 

418 all_speech_inputs, 

419 return_tensors=return_tensors, 

420 ) 

421 batch_encoding["speech_tensors"] = speech_dict["padded_speeches"] 

422 batch_encoding["speech_masks"] = speech_dict["speech_masks"] 

423 else: 

424 batch_encoding["speech_tensors"] = None 

425 batch_encoding["speech_masks"] = None 

426 

427 # Add metadata 

428 batch_encoding["parsed_scripts"] = [enc["parsed_script"] for enc in encodings] 

429 batch_encoding["all_speakers_list"] = [enc["all_speakers"] for enc in encodings] 

430 

431 return batch_encoding 

432 

433 def _create_voice_prompt( 

434 self, 

435 speaker_samples: List[Union[str, np.ndarray]] 

436 ) -> Tuple[List[int], List[np.ndarray], List[bool]]: 

437 """ 

438 Create voice prompt tokens and process audio samples. 

439 

440 Returns: 

441 tuple: (voice_tokens, voice_speech_inputs, voice_speech_masks) 

442 """ 

443 if not self.tokenizer: 

444 raise ValueError("Tokenizer is not initialized.") 

445 

446 vae_token_id = self.tokenizer.speech_diffusion_id 

447 

448 voice_full_tokens = self.tokenizer.encode(' Voice input:\n', add_special_tokens=False) 

449 voice_speech_inputs = [] 

450 voice_speech_masks = [False] * len(voice_full_tokens) 

451 

452 for speaker_id, speaker_audio in enumerate(speaker_samples): 

453 prefix_tokens = self.tokenizer.encode(f" Speaker {speaker_id}:", add_special_tokens=False) 

454 

455 # Process audio 

456 if isinstance(speaker_audio, str): 

457 # Load audio from file 

458 assert self.audio_processor is not None 

459 wav = self.audio_processor._load_audio_from_path(speaker_audio) 

460 else: 

461 wav = np.array(speaker_audio, dtype=np.float32) 

462 

463 # Apply normalization if needed 

464 if self.db_normalize and self.audio_normalizer: 

465 wav = self.audio_normalizer(wav) 

466 

467 # Calculate token length based on compression ratio 

468 # if speaker_audio.endswith('.pt') or speaker_audio.endswith('.npy'): 

469 # vae_tok_len = wav.shape[0] 

470 # else: 

471 vae_tok_len = math.ceil(wav.shape[0] / self.speech_tok_compress_ratio) 

472 

473 # Build tokens and masks 

474 speaker_tokens = ( 

475 prefix_tokens 

476 + [self.tokenizer.speech_start_id] 

477 + [vae_token_id] * vae_tok_len 

478 + [self.tokenizer.speech_end_id] 

479 + self.tokenizer.encode('\n', add_special_tokens=False) 

480 ) 

481 

482 vae_input_mask = ( 

483 [False] * len(prefix_tokens) 

484 + [False] 

485 + [True] * vae_tok_len 

486 + [False] 

487 + [False] 

488 ) 

489 

490 voice_full_tokens.extend(speaker_tokens) 

491 voice_speech_masks.extend(vae_input_mask) 

492 voice_speech_inputs.append(wav) 

493 

494 return voice_full_tokens, voice_speech_inputs, voice_speech_masks 

495 

496 def prepare_speech_inputs( 

497 self, 

498 speech_inputs: List[np.ndarray], 

499 return_tensors: Optional[Union[str, TensorType]] = None, 

500 device: Optional[Union[str, torch.device]] = None, 

501 dtype: Optional[torch.dtype] = None, 

502 ) -> Dict[str, Any]: 

503 """ 

504 Prepare speech inputs for model consumption. 

505 

506 Args: 

507 speech_inputs: List of speech arrays 

508 return_tensors: Output tensor type 

509 device: Device to place tensors on 

510 dtype: Data type for tensors 

511 

512 Returns: 

513 Dictionary with padded_speeches and speech_masks 

514 """ 

515 if not speech_inputs: 

516 return {"padded_speeches": None, "speech_masks": None} 

517 

518 # Calculate sequence lengths 

519 vae_tok_seqlens = [math.ceil(s.shape[0] / self.speech_tok_compress_ratio) for s in speech_inputs] 

520 # vae_tok_seqlens = [math.ceil(s.shape[0] / self.speech_tok_compress_ratio) 

521 # if s.ndim == 1 else s.shape[0] for s in speech_inputs] 

522 max_speech_length = max(s.shape[0] for s in speech_inputs) 

523 

524 # Pad speeches 

525 if speech_inputs[0].ndim == 1: 

526 padded_speeches = np.full((len(speech_inputs), max_speech_length), fill_value=0, dtype=np.float32) 

527 else: 

528 padded_speeches = np.full( 

529 (len(speech_inputs), max_speech_length, speech_inputs[0].shape[-1]), 

530 fill_value=0, 

531 dtype=np.float32) 

532 speech_masks = np.zeros((len(speech_inputs), max(vae_tok_seqlens)), dtype=np.bool_) 

533 

534 for i, (speech, vae_tok_length) in enumerate(zip(speech_inputs, vae_tok_seqlens)): 

535 padded_speeches[i, :len(speech)] = speech 

536 speech_masks[i, :vae_tok_length] = True 

537 

538 result: Dict[str, Any] = { 

539 "padded_speeches": padded_speeches, 

540 "speech_masks": speech_masks, 

541 } 

542 

543 # Convert to tensors if requested 

544 if return_tensors == "pt": 

545 result["padded_speeches"] = torch.tensor(padded_speeches, device=device, dtype=dtype or torch.float32) 

546 result["speech_masks"] = torch.tensor(speech_masks, device=device, dtype=torch.bool) 

547 

548 return result 

549 

550 def _convert_json_to_script(self, json_file: str) -> str: 

551 """ 

552 Convert JSON format to script format. 

553 Expected JSON format: 

554 [ 

555 {"speaker": "1", "text": "Hello everyone..."}, 

556 {"speaker": "2", "text": "Great to be here..."} 

557 ] 

558 """ 

559 import json 

560 

561 with open(json_file, 'r', encoding='utf-8') as f: 

562 data = json.load(f) 

563 

564 if not isinstance(data, list): 

565 raise ValueError("JSON file must contain a list of speaker entries") 

566 

567 script_lines = [] 

568 for item in data: 

569 if not isinstance(item, dict): 

570 logger.warning(f"Skipping non-dict entry: {item}") 

571 continue 

572 

573 speaker = item.get('speaker') 

574 text = item.get('text') 

575 

576 if speaker is None or text is None: 

577 logger.warning(f"Skipping entry missing speaker or text: {item}") 

578 continue 

579 

580 # Ensure speaker ID is valid 

581 try: 

582 speaker_id = int(speaker) 

583 except (ValueError, TypeError): 

584 logger.warning(f"Invalid speaker ID: {speaker}, skipping entry") 

585 continue 

586 

587 # Clean up text 

588 text = text.strip() 

589 if text: 

590 script_lines.append(f"Speaker {speaker_id}: {text}") 

591 

592 if not script_lines: 

593 raise ValueError("No valid entries found in JSON file") 

594 

595 return "\n".join(script_lines) 

596 

597 def _convert_text_to_script(self, text_file: str) -> str: 

598 """ 

599 Convert text file to script format. 

600 Handles multiple formats: 

601 1. Already formatted as "Speaker X: text" 

602 2. Plain text (assigns to Speaker 1) 

603 

604 Handles edge cases like multiple colons in a line. 

605 """ 

606 with open(text_file, 'r', encoding='utf-8') as f: 

607 lines = f.readlines() 

608 

609 script_lines = [] 

610 current_speaker = 1 

611 

612 for line in lines: 

613 line = line.strip() 

614 if not line: 

615 continue 

616 

617 # Try to parse as "Speaker X: text" format 

618 # Use regex to be more robust 

619 speaker_match = re.match(r'^Speaker\s+(\d+)\s*:\s*(.*)$', line, re.IGNORECASE) 

620 

621 if speaker_match: 

622 speaker_id = int(speaker_match.group(1)) 

623 text = speaker_match.group(2).strip() 

624 if text: 

625 script_lines.append(f"Speaker {speaker_id}: {text}") 

626 else: 

627 # Treat as plain text - assign to current speaker 

628 script_lines.append(f"Speaker {current_speaker}: {line}") 

629 

630 if not script_lines: 

631 raise ValueError("No valid content found in text file") 

632 

633 return "\n".join(script_lines) 

634 

635 def _parse_script(self, script: str) -> List[Tuple[int, str]]: 

636 """Parse script into list of (speaker_id, text) tuples.""" 

637 lines = script.strip().split("\n") 

638 parsed_lines = [] 

639 speaker_ids = [] 

640 

641 # First pass: parse all lines and collect speaker IDs 

642 for line in lines: 

643 if not line.strip(): 

644 continue 

645 

646 # Use regex to handle edge cases like multiple colons 

647 match = re.match(r'^Speaker\s+(\d+)\s*:\s*(.*)$', line.strip(), re.IGNORECASE) 

648 

649 if match: 

650 speaker_id = int(match.group(1)) 

651 text = ' ' + match.group(2).strip() 

652 parsed_lines.append((speaker_id, text)) 

653 speaker_ids.append(speaker_id) 

654 else: 

655 logger.warning(f"Could not parse line: '{line}'") 

656 

657 if not parsed_lines: 

658 raise ValueError("No valid speaker lines found in script") 

659 

660 # Check if we need to normalize speaker IDs (only if all are > 0) 

661 min_speaker_id = min(speaker_ids) 

662 if min_speaker_id > 0: 

663 # Normalize to start from 0 

664 normalized_lines = [] 

665 for speaker_id, text in parsed_lines: 

666 normalized_lines.append((speaker_id - 1, text)) 

667 return normalized_lines 

668 else: 

669 # Keep original IDs 

670 return parsed_lines 

671 

672 def _merge_inputs(self, text_inputs: BatchEncoding, audio_inputs: Dict) -> BatchEncoding: 

673 """Merge text and audio inputs into a single BatchEncoding.""" 

674 # Start with text inputs 

675 merged = BatchEncoding(dict(text_inputs)) 

676 

677 # Add audio-specific fields 

678 if "audio" in audio_inputs: 

679 merged["speech_inputs"] = audio_inputs["audio"] 

680 if "streaming" in audio_inputs: 

681 merged["streaming"] = audio_inputs["streaming"] 

682 

683 return merged 

684 

685 def batch_decode(self, *args: Any, **kwargs: Any) -> Any: 

686 """ 

687 This method forwards all its arguments to VibeVoiceTextTokenizer's [`~PreTrainedTokenizer.batch_decode`]. 

688 Please refer to the docstring of this method for more information. 

689 """ 

690 if not self.tokenizer: 

691 raise ValueError("Tokenizer is not initialized.") 

692 return self.tokenizer.batch_decode(*args, **kwargs) 

693 

694 def decode(self, *args: Any, **kwargs: Any) -> Any: 

695 """ 

696 This method forwards all its arguments to VibeVoiceTextTokenizer's [`~PreTrainedTokenizer.decode`]. 

697 Please refer to the docstring of this method for more information. 

698 """ 

699 if not self.tokenizer: 

700 raise ValueError("Tokenizer is not initialized.") 

701 return self.tokenizer.decode(*args, **kwargs) 

702 

703 @property 

704 def model_input_names(self) -> List[str]: 

705 """ 

706 Return the list of inputs accepted by the model. 

707 """ 

708 if not self.tokenizer or not self.audio_processor: 

709 raise ValueError("Tokenizer or audio processor is not initialized.") 

710 tokenizer_input_names = self.tokenizer.model_input_names 

711 audio_processor_input_names = self.audio_processor.model_input_names 

712 return list(dict.fromkeys( 

713 tokenizer_input_names + audio_processor_input_names + ["speech_inputs", "speech_input_mask"])) 

714 

715 def save_audio( 

716 self, 

717 audio: Union[torch.Tensor, np.ndarray, List[Union[torch.Tensor, np.ndarray]]], 

718 output_path: str = "output.wav", 

719 sampling_rate: Optional[int] = None, 

720 normalize: bool = False, 

721 batch_prefix: str = "audio_", 

722 ) -> str: 

723 """ 

724 Save audio data to a file. 

725 Args: 

726 audio (Union[torch.Tensor, np.ndarray, List[Union[torch.Tensor, np.ndarray]]]): 

727 The audio data to save. Can be a single tensor/array or a list of them. 

728 output_path (str, optional): Path to save the audio file. Defaults to "output.wav". 

729 sampling_rate (int, optional): Sampling rate for the audio. If None, uses the processor's default. 

730 normalize (bool, optional): Whether to normalize the audio before saving. Defaults to False. 

731 batch_prefix (str, optional): Prefix for batch audio files. Defaults to "audio_". 

732 Returns: 

733 str: The path to the saved audio file. 

734 """ 

735 if not self.audio_processor: 

736 raise ValueError("Audio processor is not initialized.") 

737 return self.audio_processor.save_audio( 

738 audio, output_path=output_path, sampling_rate=sampling_rate, 

739 normalize=normalize, batch_prefix=batch_prefix) 

740 

741 

742__all__ = [ 

743 "VibeVoiceProcessor", 

744]