-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.py
More file actions
869 lines (765 loc) · 37.7 KB
/
Copy pathutils.py
File metadata and controls
869 lines (765 loc) · 37.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
# utils.py
import io
import logging
import os
import shutil
import sys
import tempfile
import wave
from pathlib import Path
import numpy as np
# --- Project-Specific Imports (from other planned modules) ---
# These will be available once config.py is created and populated.
try:
from config import (
ORPHEUS_AVAILABLE_VOICES_BASE,
ORPHEUS_DEFAULT_VOICE,
ORPHEUS_GERMAN_VOICES,
SAUERKRAUT_VOICES,
)
from config import ORPHEUS_SAMPLE_RATE as DEFAULT_ORPHEUS_SR
except ImportError:
# Fallbacks if config.py is not yet available or fully populated during initial setup
logger = logging.getLogger("CrispTTS.utils_early") # Use a temporary logger if main one not set up
logger.warning("config.py not found or incomplete; using hardcoded fallbacks for some utils constants.")
DEFAULT_ORPHEUS_SR = 24000
ORPHEUS_DEFAULT_VOICE = "jana"
ORPHEUS_GERMAN_VOICES = ["jana", "thomas", "max"]
SAUERKRAUT_VOICES = ["Tom", "Anna", "Max", "Lena"]
ORPHEUS_AVAILABLE_VOICES_BASE = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
# --- Conditional Imports for Optional Features ---
try:
from bs4 import BeautifulSoup
except ImportError:
BeautifulSoup = None
try:
import markdown
try:
from markdown.extensions.wikilinks import WikiLinkExtension
except ImportError:
WikiLinkExtension = None
except ImportError:
markdown = None
WikiLinkExtension = None
try:
import pypdfium2 as pdfium
except ImportError:
pdfium = None
try:
from ebooklib import epub
except ImportError:
epub = None
# Audio libraries — soundfile is safe to import at module level (no audio
# hardware init). pydub and sounddevice are deferred to first use because
# pydub.playback imports simpleaudio/pyaudio and sounddevice wraps PortAudio,
# both of which block indefinitely on headless machines without audio hardware.
SOUNDFILE_AVAILABLE = False
try:
import soundfile as sf
SOUNDFILE_AVAILABLE = True
except ImportError:
pass
# Lazy-loaded on first use via _ensure_pydub() / _ensure_sounddevice()
PYDUB_AVAILABLE = None # None = not yet checked, True/False = result
SOUNDDEVICE_AVAILABLE = None
AudioSegment = None
pydub_play = None
sd = None
def _ensure_pydub():
"""Lazy-load pydub on first use. Returns True if available."""
global PYDUB_AVAILABLE, AudioSegment, pydub_play
if PYDUB_AVAILABLE is None:
try:
from pydub import AudioSegment as _AS
from pydub.playback import play as _play
AudioSegment = _AS
pydub_play = _play
PYDUB_AVAILABLE = True
except ImportError:
PYDUB_AVAILABLE = False
return PYDUB_AVAILABLE
def _ensure_sounddevice():
"""Lazy-load sounddevice on first use. Returns True if available."""
global SOUNDDEVICE_AVAILABLE, sd
if SOUNDDEVICE_AVAILABLE is None:
try:
import sounddevice as _sd
sd = _sd
SOUNDDEVICE_AVAILABLE = True
except ImportError:
SOUNDDEVICE_AVAILABLE = False
return SOUNDDEVICE_AVAILABLE
# --- Logger Setup ---
# Assumes logging is configured in main.py. If utils is imported before, this logger might not have full config.
logger = logging.getLogger("CrispTTS.utils")
# --- User's Orpheus Decoder (Lazy Import) ---
# decoder.py loads the SNAC model from HuggingFace at import time, which
# blocks for seconds or hangs on headless machines. Defer to first use.
_orpheus_decoder_loaded = False
user_orpheus_decoder = None
def _ensure_orpheus_decoder():
"""Lazy-load the Orpheus decoder on first use."""
global _orpheus_decoder_loaded, user_orpheus_decoder
if _orpheus_decoder_loaded:
return user_orpheus_decoder
_orpheus_decoder_loaded = True
try:
from decoder import convert_to_audio as _dec
user_orpheus_decoder = _dec
logger.info("Imported 'convert_to_audio' from decoder.py for Orpheus utilities.")
except ImportError:
logger.info("decoder.py not found — Orpheus audio decoding will use a placeholder.")
def _placeholder(multiframe, count):
return b''
user_orpheus_decoder = _placeholder
except Exception as e:
logger.warning("decoder.py import failed: %s — using placeholder.", e)
def _placeholder_err(multiframe, count):
return b''
user_orpheus_decoder = _placeholder_err
return user_orpheus_decoder
# --- SuppressOutput Context Manager ---
class SuppressOutput:
def __init__(self, suppress_stdout=True, suppress_stderr=False, use_stringio=False):
self.suppress_stdout = suppress_stdout
self.suppress_stderr = suppress_stderr
self.use_stringio = use_stringio
self._old_stdout = None
self._old_stderr = None
self._capture_buffer = None
self._devnull_fp = None
def __enter__(self):
if self.use_stringio:
self._capture_buffer = io.StringIO()
if self.suppress_stdout:
self._old_stdout = sys.stdout
sys.stdout = self._capture_buffer
if self.suppress_stderr:
self._old_stderr = sys.stderr
sys.stderr = self._capture_buffer
else:
try:
self._devnull_fp = open(os.devnull, 'w', encoding='utf-8')
if self.suppress_stdout:
self._old_stdout = sys.stdout
sys.stdout = self._devnull_fp
if self.suppress_stderr:
self._old_stderr = sys.stderr
sys.stderr = self._devnull_fp
except OSError as e:
logger.warning(f"SuppressOutput: Failed to open os.devnull: {e}. Output will not be suppressed.")
self._devnull_fp = None
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if self.suppress_stdout and self._old_stdout is not None:
sys.stdout = self._old_stdout
if self.suppress_stderr and self._old_stderr is not None:
sys.stderr = self._old_stderr
if self._capture_buffer:
self.captured_output = self._capture_buffer.getvalue()
self._capture_buffer.close()
if self._devnull_fp:
self._devnull_fp.close()
return False # Do not suppress exceptions
# --- Text Extraction Functions ---
def extract_text_from_txt(filepath: Path) -> str | None:
try:
return filepath.read_text(encoding='utf-8')
except Exception as e:
logger.error(f"Error reading TXT file {filepath}: {e}")
return None
def extract_text_from_md(filepath: Path) -> str | None:
if not markdown:
logger.error("Markdown library not available for .md extraction.")
return None
if not BeautifulSoup:
logger.error("BeautifulSoup4 library not available for .md extraction (HTML parsing).")
return None
try:
md_text = filepath.read_text(encoding='utf-8')
extensions = [WikiLinkExtension(base_url='', end_url='')] if WikiLinkExtension else [] # Basic wikilink
html = markdown.markdown(md_text, extensions=extensions)
soup = BeautifulSoup(html, "html.parser")
return soup.get_text(separator=' ', strip=True)
except Exception as e:
logger.error(f"Error reading Markdown file {filepath}: {e}")
return None
def extract_text_from_html(filepath: Path) -> str | None:
if not BeautifulSoup:
logger.error("BeautifulSoup4 library not available for .html extraction.")
return None
try:
html_content = filepath.read_text(encoding='utf-8')
soup = BeautifulSoup(html_content, "html.parser")
for script_or_style in soup(["script", "style"]):
script_or_style.decompose()
return soup.get_text(separator=' ', strip=True)
except Exception as e:
logger.error(f"Error reading HTML file {filepath}: {e}")
return None
def extract_text_from_pdf(filepath: Path) -> str | None:
if not pdfium:
logger.error("pypdfium2 library not available for .pdf extraction.")
return None
try:
text_content = []
doc = pdfium.PdfDocument(filepath) # type: ignore
for i in range(len(doc)):
page = doc.get_page(i)
textpage = page.get_textpage()
text_content.append(textpage.get_text_range())
textpage.close()
page.close()
doc.close()
return "\n".join(text_content)
except Exception as e:
logger.error(f"Error reading PDF file {filepath}: {e}")
return None
def extract_text_from_epub(filepath: Path) -> str | None:
if not epub:
logger.error("EbookLib library not available for .epub extraction.")
return None
if not BeautifulSoup:
logger.error("BeautifulSoup4 library not available for .epub extraction (content parsing).")
return None
try:
book = epub.read_epub(filepath) # type: ignore
text_parts = []
for item in book.get_items_of_type(epub.ITEM_DOCUMENT): # type: ignore
soup = BeautifulSoup(item.get_content(), "html.parser")
for script_or_style in soup(["script", "style"]):
script_or_style.decompose()
text_parts.append(soup.get_text(separator=' ', strip=True))
return "\n".join(text_parts)
except Exception as e:
logger.error(f"Error reading EPUB file {filepath}: {e}")
return None
def get_text_from_input(input_text_direct: str | None, input_file_path_str: str | None) -> str | None:
if input_text_direct:
return input_text_direct
if input_file_path_str:
filepath = Path(input_file_path_str)
if not filepath.exists():
logger.error(f"Input file not found: {input_file_path_str}")
return None
ext = filepath.suffix.lower()
if ext == '.txt':
return extract_text_from_txt(filepath)
elif ext == '.md':
return extract_text_from_md(filepath)
elif ext in ['.html', '.htm']:
return extract_text_from_html(filepath)
elif ext == '.pdf':
return extract_text_from_pdf(filepath)
elif ext == '.epub':
return extract_text_from_epub(filepath)
else:
logger.error(f"Unsupported file type: {ext} for file {filepath}")
return None
logger.info("No text input provided (neither direct text nor file).")
return None
# --- Audio Watermarking ---
# Lazy import to avoid circular deps; the watermark module is pure numpy.
_watermark_mod = None
def _get_watermark():
global _watermark_mod
if _watermark_mod is None:
try:
import watermark as wm
_watermark_mod = wm
except ImportError:
logger.debug("watermark module not available — audio will not be watermarked.")
return _watermark_mod
def _watermark_audio_bytes(audio_bytes: bytes, fmt: str, sr: int | None) -> bytes:
"""Apply watermark + metadata to audio bytes before writing to disk.
Handles WAV (watermark PCM + LIST/INFO metadata) and MP3 (ID3v2 tags).
For other formats, returns the input unchanged.
"""
wm = _get_watermark()
if wm is None:
return audio_bytes
if fmt == "wav" and len(audio_bytes) >= 44:
try:
from io import BytesIO
# Decode WAV → float32 PCM, watermark, re-encode
if SOUNDFILE_AVAILABLE:
data, samplerate = sf.read(BytesIO(audio_bytes), dtype="float32")
if data.ndim > 1:
data = data[:, 0] # mono
data = wm.watermark_embed(data)
buf = BytesIO()
sf.write(buf, data, samplerate, format="WAV", subtype="PCM_16")
audio_bytes = buf.getvalue()
# Inject LIST/INFO metadata
audio_bytes = wm.inject_wav_metadata(audio_bytes)
except Exception as e:
logger.warning("WAV watermarking failed: %s", e)
elif fmt == "mp3":
try:
audio_bytes = wm.inject_mp3_metadata(audio_bytes)
except Exception as e:
logger.warning("MP3 metadata injection failed: %s", e)
return audio_bytes
def _watermark_file_in_place(filepath: Path, fmt: str) -> None:
"""Apply watermarking to an already-written audio file."""
wm = _get_watermark()
if wm is None:
return
if fmt == "wav":
try:
if SOUNDFILE_AVAILABLE:
data, samplerate = sf.read(str(filepath), dtype="float32")
if data.ndim > 1:
data = data[:, 0]
data = wm.watermark_embed(data)
sf.write(str(filepath), data, samplerate, subtype="PCM_16")
# Inject metadata
raw = filepath.read_bytes()
patched = wm.inject_wav_metadata(raw)
filepath.write_bytes(patched)
except Exception as e:
logger.warning("WAV file watermarking failed for %s: %s", filepath, e)
elif fmt == "mp3":
try:
raw = filepath.read_bytes()
patched = wm.inject_mp3_metadata(raw)
filepath.write_bytes(patched)
except Exception as e:
logger.warning("MP3 file metadata injection failed for %s: %s", filepath, e)
# C2PA signing (optional, requires c2pa-python + certificate)
try:
wm.c2pa_sign_file(str(filepath))
except Exception as e:
logger.debug("C2PA signing skipped for %s: %s", filepath, e)
# Post-embed verification: read back and check watermark is detectable
if fmt == "wav":
try:
confidence = wm.watermark_verify_file(str(filepath))
if confidence is not None and confidence < 0.6:
logger.warning("Watermark verification LOW for %s (confidence=%.3f). "
"The watermark may not survive downstream processing.", filepath, confidence)
elif confidence is not None:
logger.debug("Watermark verified for %s (confidence=%.3f).", filepath, confidence)
except Exception as e:
logger.debug("Watermark verification skipped for %s: %s", filepath, e)
# --- Audio Handling Utilities ---
def save_audio(audio_data_or_path, output_filepath_str: str, source_is_path=False, input_format=None, sample_rate=None):
output_filepath = Path(output_filepath_str)
output_filepath.parent.mkdir(parents=True, exist_ok=True)
target_format = output_filepath.suffix[1:].lower() if output_filepath.suffix else "mp3"
if not audio_data_or_path and not source_is_path:
logger.warning(f"No audio data provided to save_audio for {output_filepath}.")
return
try:
if source_is_path:
source_path = Path(audio_data_or_path)
if not source_path.exists():
logger.error(f"Source audio path does not exist: {source_path}")
return
if source_path.suffix.lower() == f".{target_format}":
shutil.copyfile(source_path, output_filepath)
elif _ensure_pydub():
AudioSegment.from_file(source_path).export(output_filepath, format=target_format)
elif SOUNDFILE_AVAILABLE and target_format == "wav":
data, sr = sf.read(source_path)
sf.write(str(output_filepath), data, sr) # Ensure str for sf.write
else:
logger.error(f"Cannot convert {source_path.suffix} to {target_format}. Pydub needed.")
return
else: # audio_data_or_path contains audio bytes
from io import BytesIO
fmt = input_format if input_format else "wav"
if fmt == "wav_bytes":
fmt = "wav"
if fmt == "pcm_s16le":
current_sample_rate = sample_rate or DEFAULT_ORPHEUS_SR
if _ensure_pydub():
audio_segment = AudioSegment(data=audio_data_or_path, sample_width=2,
frame_rate=current_sample_rate, channels=1)
audio_segment.export(output_filepath, format=target_format)
elif SOUNDFILE_AVAILABLE and target_format == "wav":
audio_np_array = np.frombuffer(audio_data_or_path, dtype=np.int16)
sf.write(str(output_filepath), audio_np_array, current_sample_rate) # Ensure str
else:
logger.error("Cannot save raw PCM; Pydub or SoundFile (for WAV target) is required.")
return
elif _ensure_pydub():
AudioSegment.from_file(BytesIO(audio_data_or_path), format=fmt).export(output_filepath,
format=target_format)
elif SOUNDFILE_AVAILABLE and fmt == "wav" and target_format == "wav":
data, sr_read = sf.read(BytesIO(audio_data_or_path))
sf.write(str(output_filepath), data, sr_read if sr_read else (sample_rate or DEFAULT_ORPHEUS_SR))
else:
logger.error(f"Cannot save audio bytes of format '{fmt}' to '{target_format}'. Pydub or Soundfile (for WAV) needed.") # noqa: E501
return
# Apply watermarking to the saved file
_watermark_file_in_place(output_filepath, target_format)
logger.info(f"Audio saved to {output_filepath}")
except Exception as e:
logger.error(f"Error saving audio to {output_filepath}: {e}", exc_info=True)
def resample_audio(pcm: np.ndarray, from_sr: int, to_sr: int) -> np.ndarray:
"""Resample float32 mono PCM using linear interpolation.
For high-quality resampling, install scipy (uses polyphase filter).
Falls back to linear interpolation (lightweight, no extra deps).
"""
if from_sr == to_sr:
return pcm
try:
from math import gcd
from scipy.signal import resample_poly
g = gcd(to_sr, from_sr)
return resample_poly(pcm, to_sr // g, from_sr // g).astype(np.float32)
except ImportError:
pass
# Linear interpolation fallback
ratio = to_sr / from_sr
new_len = int(len(pcm) * ratio)
indices = np.arange(new_len, dtype=np.float64) / ratio
idx_floor = np.clip(np.floor(indices).astype(int), 0, len(pcm) - 1)
idx_ceil = np.clip(idx_floor + 1, 0, len(pcm) - 1)
frac = (indices - idx_floor).astype(np.float32)
return pcm[idx_floor] * (1.0 - frac) + pcm[idx_ceil] * frac
def trim_silence(pcm: np.ndarray, threshold_db: float = -40.0, min_silence_samples: int = 1000) -> np.ndarray:
"""Trim leading and trailing silence from float32 PCM audio.
Args:
pcm: 1-D float32 array of audio samples.
threshold_db: RMS threshold in dB below which audio is considered silence.
min_silence_samples: Minimum number of samples to consider as silence region.
Returns:
Trimmed copy of the PCM array.
"""
if len(pcm) == 0:
return pcm
threshold = 10 ** (threshold_db / 20.0)
frame_len = min(min_silence_samples, len(pcm))
# Find first non-silent sample
start = 0
for i in range(0, len(pcm) - frame_len, frame_len):
rms = np.sqrt(np.mean(pcm[i:i + frame_len] ** 2))
if rms > threshold:
start = max(0, i - frame_len) # keep a little context
break
# Find last non-silent sample
end = len(pcm)
for i in range(len(pcm) - frame_len, start, -frame_len):
rms = np.sqrt(np.mean(pcm[i:i + frame_len] ** 2))
if rms > threshold:
end = min(len(pcm), i + 2 * frame_len)
break
return pcm[start:end]
def trim_silence_file(filepath: str | Path, threshold_db: float = -40.0) -> None:
"""Trim leading/trailing silence from a WAV file in place."""
filepath = Path(filepath)
if not SOUNDFILE_AVAILABLE or not filepath.exists():
return
try:
data, sr = sf.read(str(filepath), dtype="float32")
if data.ndim > 1:
data = data[:, 0]
trimmed = trim_silence(data, threshold_db)
if len(trimmed) < len(data):
sf.write(str(filepath), trimmed, sr, subtype="PCM_16")
logger.info("Trimmed silence: %d → %d samples (%.1fs → %.1fs)",
len(data), len(trimmed), len(data) / sr, len(trimmed) / sr)
except Exception as e:
logger.warning("Silence trimming failed for %s: %s", filepath, e)
def crossfade_segments(segments: list[np.ndarray], crossfade_ms: float = 50.0,
sample_rate: int = 24000) -> np.ndarray:
"""Concatenate audio segments with a short crossfade to avoid clicks.
Args:
segments: List of float32 mono PCM arrays.
crossfade_ms: Crossfade duration in milliseconds (default 50 ms).
sample_rate: Audio sample rate.
Returns:
Single concatenated float32 PCM array with crossfade applied.
"""
if not segments:
return np.array([], dtype=np.float32)
if len(segments) == 1:
return segments[0]
fade_samples = int(sample_rate * crossfade_ms / 1000.0)
result = segments[0]
for seg in segments[1:]:
if len(result) < fade_samples or len(seg) < fade_samples:
# Too short for crossfade — just concatenate
result = np.concatenate([result, seg])
continue
# Linear crossfade
fade_out = np.linspace(1.0, 0.0, fade_samples, dtype=np.float32)
fade_in = np.linspace(0.0, 1.0, fade_samples, dtype=np.float32)
# Overlap region
overlap = result[-fade_samples:] * fade_out + seg[:fade_samples] * fade_in
result = np.concatenate([result[:-fade_samples], overlap, seg[fade_samples:]])
return result
def play_audio(audio_path_or_data, is_path=True, input_format=None, sample_rate=None):
if is_path:
if not _ensure_pydub():
logger.error("Pydub not available for file playback.")
return
audio_file_path = Path(audio_path_or_data)
if not audio_file_path.exists():
logger.error(f"Audio file for playback does not exist: {audio_file_path}")
return
try:
logger.info(f"Playing audio from file: {audio_file_path}...")
sound = AudioSegment.from_file(audio_file_path)
pydub_play(sound)
logger.info("Playback finished.")
except Exception as e:
logger.error(f"Error playing audio file {audio_file_path}: {e}", exc_info=True)
else: # audio_path_or_data contains audio bytes
fmt = input_format if input_format else "wav"
if fmt == "wav_bytes":
fmt = "wav"
if fmt == "pcm_s16le":
current_sample_rate = sample_rate or DEFAULT_ORPHEUS_SR
if not _ensure_sounddevice():
logger.error("Sounddevice not available for raw PCM playback.")
return
try:
logger.info(f"Playing raw PCM audio (sample rate: {current_sample_rate})...")
audio_np = np.frombuffer(audio_path_or_data, dtype=np.int16)
sd.play(audio_np, samplerate=current_sample_rate, blocking=True)
sd.wait()
logger.info("Playback finished.")
except Exception as e:
logger.error(f"Error playing PCM audio with sounddevice: {e}", exc_info=True)
elif _ensure_pydub():
from io import BytesIO
try:
logger.info(f"Playing audio bytes (format: {fmt})...")
sound = AudioSegment.from_file(BytesIO(audio_path_or_data), format=fmt)
pydub_play(sound)
logger.info("Playback finished.")
except Exception as e:
logger.error(f"Error playing audio bytes with pydub: {e}", exc_info=True)
else:
logger.error(f"Cannot play audio bytes of format '{fmt}'. Pydub or Sounddevice (for PCM) needed.")
# --- Orpheus Specific Utilities ---
def orpheus_format_prompt(prompt_text, voice_name, available_voices_list):
"""Formats prompt for Orpheus models, using globally defined voice lists if needed."""
voice_name_lower = voice_name.lower()
found_match = None
# Ensure available_voices_list is actually a list
if not isinstance(available_voices_list, list):
logger.warning(f"Orpheus: available_voices_list is not a list ({type(available_voices_list)}). Using default fallback.") # noqa: E501
available_voices_list = [] # Prevent error, will lead to fallback
for v_avail in available_voices_list:
if isinstance(v_avail, str) and v_avail.lower() == voice_name_lower:
found_match = v_avail
break
chosen_voice = voice_name
if found_match:
if voice_name != found_match:
logger.info(f"Orpheus voice '{voice_name}' matched (case-insensitively) as '{found_match}'.")
chosen_voice = found_match
elif voice_name not in available_voices_list: # Also check case-sensitively if not found case-insensitively
fallback_voice = available_voices_list[0] if available_voices_list else ORPHEUS_DEFAULT_VOICE
logger.warning(f"Orpheus voice '{voice_name}' not in available list {available_voices_list}. Using fallback '{fallback_voice}'.") # noqa: E501
chosen_voice = fallback_voice
return f"<|audio|>{chosen_voice}: {prompt_text}<|eot_id|>"
def orpheus_turn_token_into_id(token_string: str, index: int) -> int | None:
"""Converts Orpheus custom token string to its ID."""
token_string = token_string.strip()
CUSTOM_TOKEN_PREFIX = "<custom_token_" # noqa: S105
if token_string.startswith(CUSTOM_TOKEN_PREFIX) and token_string.endswith(">"):
try:
return int(token_string[len(CUSTOM_TOKEN_PREFIX):-1]) - 10 - ((index % 7) * 4096)
except ValueError:
logger.warning(f"Could not parse token_id from Orpheus token: {token_string}")
return None
return None
def _orpheus_master_token_processor_and_decoder(raw_token_text_generator, output_file_wav_str=None):
logger.debug("Orpheus Master Processor - Starting token processing and decoding.")
all_audio_data = bytearray()
wav_file = None
if output_file_wav_str:
output_p = Path(output_file_wav_str)
output_p.parent.mkdir(parents=True, exist_ok=True)
try:
wav_file = wave.open(str(output_p), "wb")
wav_file.setnchannels(1)
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(DEFAULT_ORPHEUS_SR)
except Exception as e:
logger.error(f"Orpheus Master Processor - WAV open failed for {output_file_wav_str}: {e}")
wav_file = None
token_buffer = []
token_count_for_decoder = 0
audio_segment_count = 0
id_conversion_idx = 0
try:
for text_chunk in raw_token_text_generator:
if not isinstance(text_chunk, str):
logger.warning(f"Orpheus Processor: Received non-string chunk: {type(text_chunk)}")
continue
current_pos = 0
while True:
start_custom = text_chunk.find("<custom_token_", current_pos)
if start_custom == -1:
break
end_custom = text_chunk.find(">", start_custom)
if end_custom == -1:
logger.debug(f"Orpheus Processor: Incomplete token at end of chunk: {text_chunk[start_custom:]}")
break
custom_token_str = text_chunk[start_custom : end_custom + 1]
token_id = orpheus_turn_token_into_id(custom_token_str, id_conversion_idx)
if token_id is not None and token_id > 0:
token_buffer.append(token_id)
token_count_for_decoder += 1
id_conversion_idx += 1
if token_count_for_decoder % 7 == 0 and token_count_for_decoder >= 28:
buffer_to_process = token_buffer[-28:]
if len(buffer_to_process) == 28:
try:
dec = _ensure_orpheus_decoder()
audio_chunk_bytes = dec(buffer_to_process, token_count_for_decoder)
if audio_chunk_bytes and isinstance(audio_chunk_bytes,
bytes) and len(audio_chunk_bytes) > 0:
all_audio_data.extend(audio_chunk_bytes)
audio_segment_count += 1
if wav_file:
try:
wav_file.writeframes(audio_chunk_bytes)
except Exception as e_write:
logger.warning(f"Orpheus Processor - Failed to write audio frames: {e_write}") # noqa: E501
except Exception as e_decode:
logger.error(f"Orpheus decoder error: {e_decode}", exc_info=True)
current_pos = end_custom + 1
except Exception as e_gen_loop:
logger.error(f"Orpheus Processor - Error in token generation loop: {e_gen_loop}", exc_info=True)
finally:
if wav_file:
try:
wav_file.close()
except Exception as e_close:
logger.warning(f"Orpheus Processor - Failed to close WAV file {output_file_wav_str}: {e_close}")
if not all_audio_data:
logger.warning("Orpheus Processor - No audio data was generated/decoded.")
if output_file_wav_str and Path(output_file_wav_str).exists(): # If an empty file was created
Path(output_file_wav_str).unlink(missing_ok=True)
duration_s = (len(all_audio_data) / (2 * DEFAULT_ORPHEUS_SR)) if DEFAULT_ORPHEUS_SR > 0 else 0
logger.info(f"Orpheus Processor - Processed {audio_segment_count} audio segments. Total duration: {duration_s:.2f}s.") # noqa: E501
return bytes(all_audio_data)
# --- OuteTTS Specific Utilities ---
def _prepare_oute_speaker_ref(speaker_ref_path_str: str, model_id_for_log: str ="oute"):
"""
Validates and optionally trims the OuteTTS speaker reference WAV file.
Returns a tuple: (Path object to use for speaker creation, string path of temp file to delete or None).
"""
speaker_ref_path_to_use = None
temp_trimmed_audio_path_to_delete = None # Path of temp file if trimming occurs
if not isinstance(speaker_ref_path_str, str) or not speaker_ref_path_str.strip():
logger.warning(f"{model_id_for_log} - Speaker reference path is empty or not a string: '{speaker_ref_path_str}'. Attempting fallback to ./german.wav.") # noqa: E501
speaker_ref_path_str = "./german.wav" # Force fallback check
# Handle placeholder for default and try ./german.wav explicitly
# if path seems like a placeholder or became ./german.wav
if "path/to/your" in speaker_ref_path_str or speaker_ref_path_str == "./german.wav":
german_wav_fallback = Path("./german.wav").resolve() # Resolve to make existence check robust
if german_wav_fallback.exists() and german_wav_fallback.is_file():
logger.info(f"{model_id_for_log} - Found '{german_wav_fallback}', using it as reference.")
speaker_ref_path_input = german_wav_fallback
else:
logger.error(f"{model_id_for_log} - Default speaker placeholder used or './german.wav' fallback specified, but '{german_wav_fallback}' not found. Please provide a valid --german-voice-id (WAV path).") # noqa: E501
return None, None
else:
speaker_ref_path_input = Path(speaker_ref_path_str).resolve()
if (not speaker_ref_path_input.exists()
or not speaker_ref_path_input.is_file() or speaker_ref_path_input.suffix.lower() != '.wav'):
logger.error(f"{model_id_for_log} - Speaker reference path '{speaker_ref_path_input}' is not a valid existing .wav file.") # noqa: E501
return None, None
# At this point, speaker_ref_path_input should be a valid Path object to an existing .wav file
if not _ensure_pydub():
logger.warning(f"{model_id_for_log} - Pydub not available. Cannot check/trim reference audio length. Using as is: '{speaker_ref_path_input}'. Max length for OuteTTS is ~14.5s.") # noqa: E501
return speaker_ref_path_input, None # Return original path, no temp file created
try:
logger.debug(f"{model_id_for_log} - Loading reference audio '{speaker_ref_path_input}' for duration check.")
audio_segment = AudioSegment.from_file(str(speaker_ref_path_input))
MAX_OUTE_REF_DURATION_MS = 14500
if len(audio_segment) > MAX_OUTE_REF_DURATION_MS:
logger.info(f"{model_id_for_log} - Reference audio '{speaker_ref_path_input}' ({len(audio_segment)/1000.0:.1f}s) is > {MAX_OUTE_REF_DURATION_MS/1000.0:.1f}s. Trimming to {MAX_OUTE_REF_DURATION_MS/1000.0:.1f}s.") # noqa: E501
trimmed_segment = audio_segment[:MAX_OUTE_REF_DURATION_MS]
# Create a temporary file for the trimmed audio
fd, temp_trimmed_audio_path_str = tempfile.mkstemp(suffix=".wav", prefix="trimmed_ref_")
os.close(fd) # Close the file descriptor, pydub will reopen
trimmed_segment.export(temp_trimmed_audio_path_str, format="wav")
logger.info(f"{model_id_for_log} - Using trimmed temporary audio: {temp_trimmed_audio_path_str}")
speaker_ref_path_to_use = Path(temp_trimmed_audio_path_str)
temp_trimmed_audio_path_to_delete = temp_trimmed_audio_path_str # Store string path for deletion
else:
logger.info(f"{model_id_for_log} - Reference audio '{speaker_ref_path_input}' ({len(audio_segment)/1000.0:.1f}s) is within length limits. Using original.") # noqa: E501
speaker_ref_path_to_use = speaker_ref_path_input
# temp_trimmed_audio_path_to_delete remains None
except Exception as e_audio_proc:
logger.warning(f"{model_id_for_log} - Error processing reference audio '{speaker_ref_path_input}' for length check/trim: {e_audio_proc}. Using original path without trimming attempt.") # noqa: E501
speaker_ref_path_to_use = speaker_ref_path_input # Fallback to original path
# temp_trimmed_audio_path_to_delete remains None
return speaker_ref_path_to_use, temp_trimmed_audio_path_to_delete
def get_huggingface_cache_dir() -> Path:
"""
Determines the Hugging Face Hub cache directory.
Prefers HF_HOME, then XDG_CACHE_HOME, then default ~/.cache/huggingface.
"""
if os.getenv("HF_HOME"):
return Path(os.environ["HF_HOME"])
elif os.getenv("XDG_CACHE_HOME"):
return Path(os.environ["XDG_CACHE_HOME"]) / "huggingface"
else:
return Path.home() / ".cache" / "huggingface"
# --- Informational Functions (to be called from main.py) ---
def list_available_models(models_config_dict):
"""Prints available TTS models from the provided configuration."""
print("\nAvailable TTS Models (from config):")
print("-------------------------------------------------")
if not models_config_dict:
print("No models configured.")
return
for model_id, config in models_config_dict.items():
print(f"- {model_id}:")
print(f" Notes: {config.get('notes', 'N/A')}")
print("-------------------------------------------------")
def get_voice_info(model_id_to_query, models_config_dict):
"""Prints detailed voice/speaker information for a specific model."""
# Imports from config needed for default voice names if not in model_config
from config import (
ORPHEUS_DEFAULT_VOICE as G_ORPHEUS_DEFAULT_VOICE, # Avoid name clash
)
print(f"\nVoice Information for Model: {model_id_to_query}")
print("-------------------------------------")
if model_id_to_query not in models_config_dict:
print(f"Model ID '{model_id_to_query}' not found in configuration.")
return
config = models_config_dict[model_id_to_query]
default_voice_display = "Not specified" # Fallback
# Try to get a sensible default display name
if config.get('default_voice_id'):
default_voice_display = config.get('default_voice_id')
elif config.get('default_model_path_in_repo'):
default_voice_display = config.get('default_model_path_in_repo')
elif config.get('default_speaker_embedding_index') is not None:
default_voice_display = f"Embedding Index: {config.get('default_speaker_embedding_index')}"
elif config.get('default_speaker_id') is not None:
default_voice_display = f"Speaker ID: {config.get('default_speaker_id')}"
elif "orpheus" in model_id_to_query:
default_voice_display = G_ORPHEUS_DEFAULT_VOICE
print(f" Default Voice/Speaker: {default_voice_display}")
if "available_voices" in config and config["available_voices"]:
print(" Known available custom voices/speaker identifiers (use with --german-voice-id):")
for voice in config["available_voices"]:
print(f" - {voice}")
if "test_default_speakers" in config and config["test_default_speakers"] and "oute" in model_id_to_query:
print(" OuteTTS internal default speaker IDs suggested for testing (use with --german-voice-id):")
for voice in config["test_default_speakers"]:
print(f" - {voice}")
# Model-specific guidance
if model_id_to_query == "piper_local":
print(" Piper Voice: Provide model path (e.g., 'de/de_DE/...') or JSON '{\"model\":\"...\", \"config\":\"...\"}' via --german-voice-id.") # noqa: E501
elif model_id_to_query == "speecht5_german_transformers":
print(" SpeechT5 Voice: Provide embedding index (int) or .pt/.pth xvector path via --german-voice-id.")
elif model_id_to_query == "fastpitch_german_nemo":
print(" NeMo Voice: Provide speaker ID (int) via --german-voice-id.")
elif "orpheus" in model_id_to_query:
print(" Orpheus models may support emotion tags like <laugh> in input text.")
elif model_id_to_query.startswith("oute"):
print(" OuteTTS Voice: Use OuteTTS default speaker ID or path to .wav/.json speaker file via --german-voice-id.") # noqa: E501
print("-------------------------------------")