complete restructure
This commit is contained in:
+48
-13
@@ -3,9 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
|
||||
# kokoro-tts and soundfile ship no type stubs upstream.
|
||||
import soundfile as sf # type: ignore[import-untyped]
|
||||
from kokoro_tts import ( # type: ignore[import-untyped]
|
||||
Kokoro,
|
||||
@@ -13,14 +16,31 @@ from kokoro_tts import ( # type: ignore[import-untyped]
|
||||
process_chunk_sequential,
|
||||
)
|
||||
|
||||
from vibe_bot.config import TTS_SPEED, TTS_VOICE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default voice settings
|
||||
DEFAULT_VOICE = "af_sarah"
|
||||
DEFAULT_SPEED = 1.0
|
||||
# Default voice settings (single source of truth: vibe_bot.config).
|
||||
DEFAULT_VOICE = TTS_VOICE
|
||||
DEFAULT_SPEED = TTS_SPEED
|
||||
DEFAULT_LANG = "en-us"
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioResult:
|
||||
"""Audio output from a TTS generation.
|
||||
|
||||
Attributes:
|
||||
audio: The encoded audio (MP3) as a seekable BytesIO.
|
||||
partial: True if one or more text chunks failed to produce audio.
|
||||
failed_chunks: Number of text chunks that failed.
|
||||
"""
|
||||
|
||||
audio: BytesIO
|
||||
partial: bool
|
||||
failed_chunks: int
|
||||
|
||||
|
||||
class TTSEngine:
|
||||
"""Text-to-speech engine wrapper around Kokoro TTS."""
|
||||
|
||||
@@ -43,13 +63,14 @@ class TTSEngine:
|
||||
voice: str = DEFAULT_VOICE,
|
||||
speed: float = DEFAULT_SPEED,
|
||||
lang: str = DEFAULT_LANG,
|
||||
) -> BytesIO:
|
||||
"""Convert text to audio and return as BytesIO (MP3 format)."""
|
||||
) -> AudioResult:
|
||||
"""Convert text to audio and return an AudioResult (MP3 in .audio)."""
|
||||
all_samples: list[np.ndarray] = []
|
||||
sample_rate: int | None = None
|
||||
failed_chunks = 0
|
||||
|
||||
chunks: list[str] = list(chunk_text(text))
|
||||
logger.info("Split text into %d chunks", len(chunks))
|
||||
logger.debug("Split text into %d chunks", len(chunks))
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
try:
|
||||
@@ -60,15 +81,21 @@ class TTSEngine:
|
||||
speed,
|
||||
lang,
|
||||
)
|
||||
if samples is not None:
|
||||
if sample_rate is None:
|
||||
sample_rate = sr
|
||||
all_samples.append(np.asarray(samples))
|
||||
logger.info("Processed chunk %d/%d", i + 1, len(chunks))
|
||||
except Exception:
|
||||
logger.exception("Error processing chunk %d", i + 1)
|
||||
failed_chunks += 1
|
||||
continue
|
||||
|
||||
if samples is None:
|
||||
logger.warning("Chunk %d/%d produced no audio", i + 1, len(chunks))
|
||||
failed_chunks += 1
|
||||
continue
|
||||
|
||||
if sample_rate is None:
|
||||
sample_rate = sr
|
||||
all_samples.append(np.asarray(samples))
|
||||
logger.debug("Processed chunk %d/%d", i + 1, len(chunks))
|
||||
|
||||
if not all_samples:
|
||||
msg = "No audio samples generated - text may be invalid or too long"
|
||||
raise ValueError(msg)
|
||||
@@ -85,9 +112,17 @@ class TTSEngine:
|
||||
)
|
||||
buffer.seek(0)
|
||||
|
||||
logger.info(
|
||||
partial = failed_chunks > 0
|
||||
if partial:
|
||||
logger.warning(
|
||||
"TTS produced partial audio: %d of %d chunks failed",
|
||||
failed_chunks,
|
||||
len(chunks),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"Generated MP3 audio: %d samples at %dHz",
|
||||
len(combined),
|
||||
sample_rate or 0,
|
||||
)
|
||||
return buffer
|
||||
return AudioResult(audio=buffer, partial=partial, failed_chunks=failed_chunks)
|
||||
|
||||
Reference in New Issue
Block a user